前面我們使用簡單的例子演示了 Task 和 Thread 的兩種製造曇花線程的方式。那麼除了避免曇花線程,在實現常駐任務的時候,還需要避免重返線程池。本文將介紹如何避免重返線程池。
常駐任務
常駐任務非常常見,比如:
- 我們正在編寫一個日誌文件庫,我們希望在後臺不斷的將日誌寫入文件,儘可能不影響業務線程的執行。因此,需要一個寫文件的常駐任務。
- 我們對接了一個遠程 TCP 服務,對方要求我們每隔一段時間發送一個心跳包,以保持連接。因此,需要一個發送心跳包的常駐任務。
- 我們編寫了一個簡單的內存緩存,通過一個後臺任務來定期清理過期的緩存。因此,需要一個清理緩存的常駐任務。
類似的場景還有很多。因此,我們需要一個能夠實現常駐任務的方法。
而實現常駐任務的主要要點是:
- 常駐任務必須避免影響業務線程的執行,因此需要在後臺執行。
- 常駐任務不能被業務線程影響,無論當前業務多麼繁忙,常駐任務都必須能夠正常執行。否則會出現日誌不落盤,心跳包不發送,緩存不清理等問題。
實現常駐任務的手段有很多。本文將圍繞如何使用常駐單一線程來實現常駐任務。
所謂常駐單一線程,就是指始終使用一個線程來執行常駐任務。從而達到:
- 避免頻繁的創建和銷燬線程,從而避免頻繁的線程切換。
- 更容易的處理背壓問題。
- 更容易的處理線程安全問題。
評測主體
我們將採用如下情況來評測如何編寫常駐任務的正確性。
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 方法,用於評測常駐任務的正確性。我們將在這個方法中啓動常駐任務,然後執行一個嚴架給壓力的方法,來模擬非常繁忙的業務操作。最後我們將輸出常駐任務中的計數器的值。
可以初步看一下嚴架帶來的壓力有多大:
然後我們不妨假設,我們的常駐任務是希望每秒進行一次計數。那麼最終在控制檯輸出的結果應該是 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。這是因爲:
- Task.Run 會將我們的任務放入 Task Default Scheduler 線程池中執行。
- 但是由於迫於嚴架給壓力,我們的業務線程會一直處於繁忙狀態,因此線程池中的線程也會一直處於繁忙狀態。
- 從而日導致我們的常駐任務無法正常執行。
這裏我們可以看到,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 就會被延遲執行,因此就會出現錯誤的情況。
總結
- 在全同步的情況下,我們可以使用 TaskCreationOptions.LongRunning 或者 Thread 來實現常駐單一線程。從而實現穩定的常駐任務。
- 注意 async/await 可能會導致線程池的使用,從而避免常駐單一線程被破壞。
- 我們暫未給出帶有異步代碼的情況下如何實現穩定的常駐任務,我們將在後續討論。
測試代碼:https://github.com/newbe36524/Newbe.Demo/tree/main/src/BlogDemos/Newbe.LongRunningJob
參考
- .NET Task 揭祕(2):Task 的回調執行與 await1
- Task2
- TaskCreationOptions3
- 這樣在 C# 使用 LongRunningTask 是錯的4
- async 與 Thread 的錯誤結合5
感謝閱讀,如果覺得本文有用,不妨點擊推薦👍或者在評論區留下 Mark,讓更多的人可以看到。
- 本文作者: newbe36524
- 本文鏈接: https://www.newbe.pro/Others/0x028-avoid-return-to-threadpool-in-longrunning-task/
- 版權聲明: 本博客所有文章除特別聲明外,均採用 BY-NC-SA 許可協議。轉載請註明出處!
-
https://www.cnblogs.com/eventhorizon/p/15912383.html↩
-
https://threads.whuanle.cn/3.task/↩
-
https://learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskcreationoptions?view=net-7.0&WT.mc_id=DX-MVP-5003606↩
-
https://www.newbe.pro/Others/0x026-This-is-the-wrong-way-to-use-LongRunnigTask-in-csharp/↩
-
https://www.newbe.pro/Others/0x027-error-when-using-async-with-thread/↩