天天看點

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

作者:newbe

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

如何識别目前代碼跑在什麼線程上

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

最簡單的方式就是列印目前線程名稱和線程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 方法。

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

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

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

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

總結

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

  1. 加配,以避免線程池不夠用
  2. 考慮在這部分代碼中使用同步代碼
  3. 可以學習自定義 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 許可協定。轉載請注明出處!
  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↩

繼續閱讀