前文我們總結了在使用常駐任務實作常駐線程時,應該注意的事項。但是我們最終沒有提到如何在處理對于帶有異步代碼的辦法。本篇将接受筆者對于該内容的總結。
如何識别目前代碼跑在什麼線程上
一切開始之前,我們先來使用一種簡單的方式來識别目前代碼運作在哪種線程上。
最簡單的方式就是列印目前線程名稱和線程ID來識别。
private static void ShowCurrentThread(string work)
{
Console.WriteLine(#34;{work} - {Thread.CurrentThread.Name} - {Thread.CurrentThread.ManagedThreadId}");
}
通過這段代碼,我們可以非常容易的識别三種不同情況下的線程資訊。
[Test]
public void ShowThreadMessage()
{
new Thread(() => { ShowCurrentThread("Custom thread work"); })
{
IsBackground = true,
Name = "Custom thread"
}.Start();
Task.Run(() => { ShowCurrentThread("Task.Run work"); });
Task.Factory.StartNew(() => { ShowCurrentThread("Task.Factory.StartNew work"); },
TaskCreationOptions.LongRunning);
Thread.Sleep(TimeSpan.FromSeconds(1));
}
// output
// Task.Factory.StartNew work - .NET Long Running Task - 17
// Custom thread work - Custom thread - 16
// Task.Run work - .NET ThreadPool Worker - 12
分别為:
- 自定義線程 Custom thread
- 線程池線程 .NET ThreadPool Worker
- 由 Task.Factory.StartNew 建立的新線程 .NET Long Running Task
是以,結合我們之前昙花線程的例子,我們也可以非常簡單的看出線程的切換情況:
[Test]
public void ShortThread()
{
new Thread(async () =>
{
ShowCurrentThread("before await");
await Task.Delay(TimeSpan.FromSeconds(0.5));
ShowCurrentThread("after await");
})
{
IsBackground = true,
Name = "Custom thread"
}.Start();
Thread.Sleep(TimeSpan.FromSeconds(1));
}
// output
// before await - Custom thread - 16
// after await - .NET ThreadPool Worker - 6
我們希望在同一個線程上運作 Task 代碼
之前我們已經知道了,手動建立線程并控制線程的運作,可以確定自己的代碼不會于線程池線程産生競争,進而使得我們的常駐任務能夠穩定的觸發。
當時用于示範的錯誤示例是這樣的:
[Test]
public void ThreadWaitTask()
{
new Thread(async () =>
{
ShowCurrentThread("before await");
Task.Run(() =>
{
ShowCurrentThread("inner task");
}).Wait();
ShowCurrentThread("after await");
})
{
IsBackground = true,
Name = "Custom thread"
}.Start();
Thread.Sleep(TimeSpan.FromSeconds(1));
}
// output
// before await - Custom thread - 16
// inner task - .NET ThreadPool Worker - 13
// after await - Custom thread - 16
這個示例可以明顯的看出,中間的部分代碼是運作線上程池的。這種做法會線上程池資源緊張的時候,導緻我們的常駐任務無法觸發。
是以,我們需要一種方式來確定我們的代碼在同一個線程上運作。
那麼接下來我們分析一些想法和效果。
加配!加配!加配!
我們已經知道了,實際上,常駐任務不能穩定觸發是因為 Task 會線上程池中運作。那麼增加線程池的容量自然就是最直接解決高峰的做法。 是以,如果條件允許的話,直接增加 CPU 核心數實際上是最為有效和簡單的方式。
不過這種做法并不适用于一些類庫的編寫者。比如,你在編寫日志類庫,那麼其實無法欲知使用者所處的環境。并且正如大家所見,市面上幾乎沒有日志類庫中由說明讓使用者隻能在一定的 CPU 核心數下使用。
是以,如果您的常駐任務是在類庫中,那麼我們需要一種更為通用的方式來解決這個問題。
考慮使用同步重載
在 Task 出現之後,很多時候我們都會考慮使用異步重載的方法。這顯然不是錯誤的做法,因為這可以使得我們的代碼更加高效,提升系統的吞吐量。但是,如果你想要讓 Thread 穩定的在同一個線程上運作,那麼你需要考慮使用同步重載的方法。通過同步重載方法,我們的代碼将不會出現線程切換到線程池的情況。自然也就實作了我們的目的。
總是使用 TaskCreationOptions.LongRunning
這個辦法其實很不實際。因為任何一層沒有指定,都會将任務切換到線程池中。
[Test]
public void AlwaysLogRunning()
{
new Thread(async () =>
{
ShowCurrentThread("before await");
Task.Factory.StartNew(() =>
{
ShowCurrentThread("LongRunning task");
Task.Run(() => { ShowCurrentThread("inner task"); }).Wait();
}, TaskCreationOptions.LongRunning).Wait();
ShowCurrentThread("after await");
})
{
IsBackground = true,
Name = "Custom thread"
}.Start();
Thread.Sleep(TimeSpan.FromSeconds(1));
}
// output
// before await - Custom thread - 16
// LongRunning task - .NET Long Running Task - 17
// inner task - .NET ThreadPool Worker - 7
// after await - Custom thread - 16
是以說,這個辦法可以用。但其實很怪。
自定義 Scheduler
這是一種可行,但是非常困難的做法。雖然說自定義個簡單的 Scheduler 也不是很難,隻需要實作幾個簡單的方法。但要按照我們的需求來實作這個 Scheduler 并不簡單。
比如我們嘗試實作一個這樣的 Scheduler:
注意:這個 Scheduler 并不能正常工作。
class MyScheduler : TaskScheduler
{
private readonly Thread _thread;
private readonly ConcurrentQueue<Task> _tasks = new();
public MyScheduler()
{
_thread = new Thread(() =>
{
while (true)
{
while (_tasks.TryDequeue(out var task))
{
TryExecuteTask(task);
}
}
})
{
IsBackground = true,
Name = "MyScheduler"
};
_thread.Start();
}
protected override IEnumerable<Task> GetScheduledTasks()
{
return _tasks;
}
protected override void QueueTask(Task task)
{
_tasks.Enqueue(task);
}
protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued)
{
return false;
}
}
上面的代碼中,我們期待通過一個單一的線程來執行所有的任務。但實際上它反而是一個非常簡單的死鎖示範裝置。
我們設想運作下面這段代碼:
[Test]
public async Task TestLongRunningConfigureAwait()
{
var scheduler = new MyScheduler();
await Task.Factory.StartNew(() =>
{
ShowCurrentThread("BeforeWait");
Task.Factory
.StartNew(() =>
{
ShowCurrentThread("AfterWait");
}
, CancellationToken.None, TaskCreationOptions.None, scheduler)
.Wait();
ShowCurrentThread("AfterWait");
}, CancellationToken.None, TaskCreationOptions.None, scheduler);
}
這段代碼中,我們期待,在一個 Task 中運作另外一個 Task。但實際上,這段代碼會死鎖。
因為,我們的 MyScheduler 中,我們在一個死循環中,不斷的從隊列中取出任務并執行。但是,我們的任務中,又會調用 Wait 方法。
我們不妨設想這個線程就是我們自己。
- 首先,老闆交代給你一件任務,你把它放到隊列中。
- 然後你開始執行這件任務,執行到一半發現,你需要等待第二件任務的執行結果。是以你在這裡等着。
- 但是第二件任務這個時候也塞到了你的隊列中。
- 這下好了,你手頭的任務在等待你隊列裡面的任務完成。而你隊列的任務隻有你才能完成。
- 完美卡死。
是以,其實實際上我們需要在 Wait 的時候通知目前線程,此時線程被 Block 了,然後轉而從隊列中取出任務執行。在 Task 于 ThreadPool 的配合中,是存在這樣的機制的。但是,我們自己實作的 MyScheduler 并不能與 Task 産生這種配合。是以需要考慮自定義一個 Task。跟進一步說,我們需要自定義 AsyncMethodBuilder 來實作全套的自定義。
顯然者是一項相對進階内容,期待了解的讀者,可以通過 UniTask1 項目來了解如何實作這樣的全套自定義。
總結
如果你期望在常駐線程能夠穩定的運作你的任務。那麼:
- 加配,以避免線程池不夠用
- 考慮在這部分代碼中使用同步代碼
- 可以學習自定義 Task 系統
參考
- .NET Task 揭秘(2):Task 的回調執行與 await2
- Task3
- TaskCreationOptions4
- 這樣在 C# 使用 LongRunningTask 是錯的5
- async 與 Thread 的錯誤結合6
- 實作常駐任務除了避免昙花線程,還需要避免重返線程池7
感謝您的閱讀,如果您覺得本文有用,快點選下方點贊按鈕♥️,讓更多的人看到本文。
歡迎關注作者的微信公衆号“newbe技術專欄”,擷取更多技術内容。 歡迎加入Q群 610394020,進入騰訊最新功能類Discord功能: QQ 頻道。 在 QQ 頻道中,你可以:
在技術專區發帖求教 在摸魚頻道暢快遨遊 在會議頻道一同讨論 讓我們一同打造和諧社群。
- 本文作者: newbe36524
- 本文連結: https://www.newbe.pro/Others/0x029-I-can-not-manage-to-always-run-task-on-one-thread/
- 版權聲明: 本部落格所有文章除特别聲明外,均采用 BY-NC-SA 許可協定。轉載請注明出處!
- https://github.com/Cysharp/UniTask↩
- 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/↩
- https://www.newbe.pro/Others/0x028-avoid-return-to-threadpool-in-longrunning-task↩