實現常駐任務除了避免曇花線程,還需要避免重返線程池

前面我們使用簡單的例子演示了 Task 和 Thread 的兩種製造曇花線程的方式。那麼除了避免曇花線程,在實現常駐任務的時候,還需要避免重返線程池。本文將介紹如何避免重返線程池。

常駐任務

常駐任務非常常見,比如:

  1. 我們正在編寫一個日誌文件庫,我們希望在後臺不斷的將日誌寫入文件,儘可能不影響業務線程的執行。因此,需要一個寫文件的常駐任務。
  2. 我們對接了一個遠程 TCP 服務,對方要求我們每隔一段時間發送一個心跳包,以保持連接。因此,需要一個發送心跳包的常駐任務。
  3. 我們編寫了一個簡單的內存緩存,通過一個後臺任務來定期清理過期的緩存。因此,需要一個清理緩存的常駐任務。

類似的場景還有很多。因此,我們需要一個能夠實現常駐任務的方法。

而實現常駐任務的主要要點是:

  1. 常駐任務必須避免影響業務線程的執行,因此需要在後臺執行。
  2. 常駐任務不能被業務線程影響,無論當前業務多麼繁忙,常駐任務都必須能夠正常執行。否則會出現日誌不落盤,心跳包不發送,緩存不清理等問題。

實現常駐任務的手段有很多。本文將圍繞如何使用常駐單一線程來實現常駐任務。

所謂常駐單一線程,就是指始終使用一個線程來執行常駐任務。從而達到:

  1. 避免頻繁的創建和銷燬線程,從而避免頻繁的線程切換。
  2. 更容易的處理背壓問題。
  3. 更容易的處理線程安全問題。

評測主體

我們將採用如下情況來評測如何編寫常駐任務的正確性。

private int _count = 0;

private void ProcessTest(Action<CancellationToken> action, [CallerMemberName] string methodName = "")
{
    var cts = new CancellationTokenSource();
    // 啓動常駐線程
    action.Invoke(cts.Token);
    // 嚴架給壓力
    YanjiaIsComing(cts.Token);

    // 等待一段時間
    Thread.Sleep(TimeSpan.FromSeconds(5));
    cts.Cancel();

    // 輸出結果
    Console.WriteLine($"{methodName}: count = {_count}");
}

private void YanjiaIsComing(CancellationToken token)
{
    Parallel.ForEachAsync(Enumerable.Range(0, 1_000_000), token, (i, c) =>
    {
        while (true)
        {
            // do something
            c.ThrowIfCancellationRequested();
        }
    });
}

這裏我們定義了一個 ProcessTest 方法,用於評測常駐任務的正確性。我們將在這個方法中啓動常駐任務,然後執行一個嚴架給壓力的方法,來模擬非常繁忙的業務操作。最後我們將輸出常駐任務中的計數器的值。

可以初步看一下嚴架帶來的壓力有多大:

CPU 100

然後我們不妨假設,我們的常駐任務是希望每秒進行一次計數。那麼最終在控制檯輸出的結果應該是 5 或者 6。但如果小於 5,那麼就說明我們的常駐任務有問題。

比如下面這樣:

[Test]
public void TestTaskRun_Error()
{
    ProcessTest(token =>
    {
        Task.Run(async () =>
        {
            while (true)
            {
                _count++;
                await Task.Delay(TimeSpan.FromSeconds(1), token);
            }
        }, token);
    });
    // TestTaskRun_Error: count = 1
}

在該測試中,我們希望使用 Task.Run 來執行我們期待的循環,進行每秒加一的操作。但是,我們發現,最終輸出的結果是 1。這是因爲:

  1. Task.Run 會將我們的任務放入 Task Default Scheduler 線程池中執行。
  2. 但是由於迫於嚴架給壓力,我們的業務線程會一直處於繁忙狀態,因此線程池中的線程也會一直處於繁忙狀態。
  3. 從而日導致我們的常駐任務無法正常執行。

這裏我們可以看到,Task.Run 並不是一種正確的實現常駐任務的方法。當然實際上這也不是常駐單一線程,因爲這樣本質是使用了線程池。

全同步過程

結合我們之前提到的 TaskCreationOptions.LongRunning 以及 Thread 很容易在全同步的情況下實現常駐單一線程。

[Test]
public void TestSyncTaskLongRunning_Success()
{
    ProcessTest(token =>
    {
        Task.Factory.StartNew(() =>
        {
            while (true)
            {
                _count++;
                Thread.Sleep(TimeSpan.FromSeconds(1));
            }
        }, token, TaskCreationOptions.LongRunning, TaskScheduler.Current);
    });
    // TestSyncTaskLongRunning_Success: count = 6
}


[Test]
public void TestThread_Success()
{
    ProcessTest(token =>
    {
        new Thread(() =>
        {
            while (true)
            {
                _count++;
                Thread.Sleep(TimeSpan.FromSeconds(1));
                if (token.IsCancellationRequested)
                {
                    return;
                }
            }
        })
        {
            IsBackground = true,
        }.Start();
    });
    // TestThread_Success: count = 6
}

這兩種正確的寫法都實現了常駐單一線程,因此我們可以看到,最終輸出的結果都是 6。

曇花線程

那麼自然,我們也可以知道,如果混合了曇花線程,那麼就會出現問題。

[Test]
public void TestAsyncTaskLongRunning_Error()
{
    ProcessTest(token =>
    {
        Task.Factory.StartNew(async () =>
        {
            while (true)
            {
                _count++;
                await Task.Delay(TimeSpan.FromSeconds(1), token);
            }
        }, token, TaskCreationOptions.LongRunning, TaskScheduler.Current);
    });
    // TestAsyncTaskLongRunning_Error: count = 1
}

[Test]
public void TestThreadWithAsync_Error()
{
    ProcessTest(token =>
    {
        Task CountUp(CancellationToken c)
        {
            _count++;
            return Task.CompletedTask;
        }

        new Thread(async () =>
        {
            while (true)
            {
                try
                {
                    await CountUp(token);
                    await Task.Delay(TimeSpan.FromSeconds(1), token);
                    token.ThrowIfCancellationRequested();
                }
                catch (OperationCanceledException e)
                {
                    return;
                }
            }
        })
        {
            IsBackground = true,
        }.Start();
    });
    // TestThreadWithAsync_Error: count = 1
}

這兩種錯誤的寫法都無法實現常駐單一線程,因此我們可以看到,最終輸出的結果都是 1。

不是有 Task 就是異步的

雖然不是本篇的關鍵內容,但是還是額外補充兩個 case 作爲對比:

[Test]
public void TestThreadWithTask_Success()
{
    ProcessTest(token =>
    {
        Task CountUp(CancellationToken c)
        {
            _count++;
            return Task.CompletedTask;
        }

        new Thread(() =>
        {
            while (true)
            {
                try
                {
                    CountUp(token).Wait(token);
                    Thread.Sleep(TimeSpan.FromSeconds(1));
                }
                catch (OperationCanceledException e)
                {
                    return;
                }
            }
        })
        {
            IsBackground = true,
        }.Start();
    });
    // TestThreadWithTask_Success: count = 6
}

[Test]
public void TestThreadWithDelayTask_Error()
{
    ProcessTest(token =>
    {
        Task CountUp(CancellationToken c)
        {
            _count++;
            return Task.Delay(TimeSpan.FromSeconds(1), c);
        }

        new Thread(() =>
        {
            while (true)
            {
                try
                {
                    CountUp(token).Wait(token);
                    token.ThrowIfCancellationRequested();
                }
                catch (OperationCanceledException e)
                {
                    return;
                }
            }
        })
        {
            IsBackground = true,
        }.Start();
    });
    // TestThreadWithDelayTask_Error: count = 1
}

在這兩個 case 但中,雖然在 while 中包含了 wait Task,但是由於 Task.CompletedTask 實際上是一種同步代碼,所以並不會進入到線程池當中。因此也就不會出現錯誤的情況。

但是這種錯誤的原因不是因爲曇花線程,是由於我們在 Thread 中進行了 Wait,但是被調用的 Task 如果確實是一個異步的 Task,那麼由於線程池繁忙,我們的 Task 就會被延遲執行,因此就會出現錯誤的情況。

總結

  1. 在全同步的情況下,我們可以使用 TaskCreationOptions.LongRunning 或者 Thread 來實現常駐單一線程。從而實現穩定的常駐任務。
  2. 注意 async/await 可能會導致線程池的使用,從而避免常駐單一線程被破壞。
  3. 我們暫未給出帶有異步代碼的情況下如何實現穩定的常駐任務,我們將在後續討論。

測試代碼:https://github.com/newbe36524/Newbe.Demo/tree/main/src/BlogDemos/Newbe.LongRunningJob

參考

感謝閱讀,如果覺得本文有用,不妨點擊推薦👍或者在評論區留下 Mark,讓更多的人可以看到。


  1. https://www.cnblogs.com/eventhorizon/p/15912383.html

  2. https://threads.whuanle.cn/3.task/

  3. https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcreationoptions?view=net-7.0&WT.mc_id=DX-MVP-5003606

  4. https://www.newbe.pro/Others/0x026-This-is-the-wrong-way-to-use-LongRunnigTask-in-csharp/

  5. https://www.newbe.pro/Others/0x027-error-when-using-async-with-thread/

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章