我沒能實現始終在一個線程上運行 task

前文我們總結了在使用常駐任務實現常駐線程時,應該注意的事項。但是我們最終沒有提到如何在處理對於帶有異步代碼的辦法。本篇將接受筆者對於該內容的總結。

如何識別當前代碼跑在什麼線程上

一切開始之前,我們先來使用一種簡單的方式來識別當前代碼運行在哪種線程上。

最簡單的方式就是打印當前線程名稱和線程ID來識別。

private static void ShowCurrentThread(string work)
{
    Console.WriteLine($"{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 方法。

我們不妨設想這個線程就是我們自己。

  1. 首先,老闆交代給你一件任務,你把它放到隊列中。
  2. 然後你開始執行這件任務,執行到一半發現,你需要等待第二件任務的執行結果。因此你在這裏等着。
  3. 但是第二件任務這個時候也塞到了你的隊列中。
  4. 這下好了,你手頭的任務在等待你隊列裏面的任務完成。而你隊列的任務只有你才能完成。
  5. 完美卡死。

因此,其實實際上我們需要在 Wait 的時候通知當前線程,此時線程被 Block 了,然後轉而從隊列中取出任務執行。在 Task 於 ThreadPool 的配合中,是存在這樣的機制的。但是,我們自己實現的 MyScheduler 並不能與 Task 產生這種配合。因此需要考慮自定義一個 Task。跟進一步說,我們需要自定義 AsyncMethodBuilder 來實現全套的自定義。

顯然者是一項相對高級內容,期待了解的讀者,可以通過 UniTask1 項目來了解如何實現這樣的全套自定義。

總結

如果你期望在常駐線程能夠穩定的運行你的任務。那麼:

  1. 加配,以避免線程池不夠用
  2. 考慮在這部分代碼中使用同步代碼
  3. 可以學習自定義 Task 系統

參考

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

歡迎關注作者的微信公衆號“newbe技術專欄”,獲取更多技術內容。 關注微信公衆號“newbe技術專欄”


  1. https://github.com/Cysharp/UniTask

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

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

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

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

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

  7. https://www.newbe.pro/Others/0x028-avoid-return-to-threadpool-in-longrunning-task

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