多線程併發如何高效實現生產者/消費者?

前言

無需引入第三方消息隊列組件,我們如何利用內置C#語法高效實現生產者/消費者對數據進行處理呢?在.NET Core共享框架(Share Framework)引入了通道(Channel),也就是說無需額外通過NuGet包安裝,若爲.NET Framework則需通過NuGet安裝,前提是版本必須是4.6+(包含4.6),查詢網上資料少的可憐,估計也有部分童鞋都沒聽說這玩意,所以接下來將通過幾篇文章詳細介紹其使用和底層具體實現原理

生產者/消費者概念

生產者/消費者這一概念,相信我們大家都不陌生,在日常生活無處不在、隨處可見,其本質可用一句話概括:具有多個連續步驟的工作流程。比如美團外賣、再比如工廠裏面的流水作業線、又比如線下實體快餐店等等。整個過程如同一條鏈,在這個鏈中每個步驟必須被完全隔離執行,生產者產生“東西”,然後對其交由下一步驟進行處理,最終到達消費者。

 

上述敘述爲一切抽象,我們回到軟件領域,在軟件中每一塊都在對應的線程中執行,以確保數據能得到正確處理,當然,這也就包括跨線程共享數據可能引起的併發問題。未出現該庫之前,我們可利用內置BlockingCollection實現生產者/消費者機制,但依然無法解決我們所面臨的兩個問題:其一:阻塞問題,其二:無任何基於Task的異步APi執行異步操作。通過引入System.Threading.Channel庫則可以完美解決生產者/消費者問題,毫無疑問,線程安全是前提,性能測試有保證,異步提高吞吐量,配置選項夠靈活。目前來看,利用通道可能將是實現生產者/消費者的最終手段

通道(Channel)概念

名爲通道還是比較形象,如同管道一樣,說到底就是線程安全的隊列,既然是隊列,那麼勢必涉及邊界問題,通道類型分爲有界通道和無界通道。

有界通道(Bounded Channel):對傳入數據具有指定容量,這也就意味着,若生產者產生的數據一旦達到容量空間,將不得不等待消費者執行完爲生產者推送數據騰出額外可用空間

無界通道:(Unbounded Channel):對傳入數據無上限,這也就意味着生產者可以持續不斷髮布數據,以此希望消費者能跟上生產者的節奏

到這裏我們完全可得出一結論:因通道提供有界和無界選項,所以內置不可能利用併發隊列來實現,一定是通過鏈表數據結構實現隊列機制。那麼問題來了,全部指定爲無界通道豈不萬事大吉,這個問題想想就有問題,雖說無界通道爲毫無上限,但計算機的系統內存不是,無論是有界通道抑或是無界通道都會通過緩存區來存儲數據。所以選擇正確的通道類型,取決於業務上下文。那麼問題又來了,若創建有界通道,一旦達到容量限制,通道應該如何處理呢?別擔心,這個事情則交由我們根據實際業務情況來處理,邊界通道容量滿模式(BoundedChannelFullMode)枚舉

💡 Wait: 等待可用空間以完成寫操作

💡 DropNewest: 直接刪除並忽略通道中的最新數據,以便爲待寫入數據騰出空間

💡 DropOldest: 直接刪除並忽略通道中的最舊數據,以便爲待寫入數據騰出空間

💡 DropWrite: 直接刪除要寫入的數據

我們通過如下簡單3個步驟實現生產者/消費者

創建通道類型

//創建通道類型
public static class Channel
{
    //有界通道(指定容量)
    public static Channel<T> CreateBounded<T>(int capacity);

    //有界通道(指定容量、配置通道滿模式選項、配置讀(是否單個讀取)、寫(是否單個寫入)、是否允許延續同步操作)
    public static Channel<T> CreateBounded<T>(BoundedChannelOptions options);

    //無界通道
    public static Channel<T> CreateUnbounded<T>();

    //無界通道(配置讀(是否單個讀取)、寫(是否單個寫入)、是否允許延續同步操作)
    public static Channel<T> CreateUnbounded<T>(UnboundedChannelOptions options);
}

創建生產者

//向通道寫入數據(生產者)
public abstract class ChannelWriter<T>
{  
    protected ChannelWriter();  

    //標識寫入通道完成,不再有數據寫入
    public void Complete(Exception error = null);  

    //嘗試向通道寫入數據,若被寫入則返回true,否則爲false
    public abstract bool TryWrite(T item);

    //異步返回通道是否有可寫入空間
    public abstract ValueTask<bool> WaitToWriteAsync(CancellationToken cancellationToken = default);

    //異步寫入數據到通道
    public virtual ValueTask WriteAsync(T item, CancellationToken cancellationToken = default);
}

創建消費者

//從通道讀取數據(消費者)
public abstract class ChannelReader<T>
{
    protected ChannelReader();

    public virtual Task Completion { get; }

    //異步讀取通道所有數據
    public virtual IAsyncEnumerable<T> ReadAllAsync([EnumeratorCancellation] CancellationToken cancellationToken = default);

    //異步讀取通道每一項數據
    public virtual ValueTask<T> ReadAsync(CancellationToken cancellationToken = default);

    //嘗試向通道讀取數據
    public abstract bool TryRead(out T item);

    //異步返回通道是否有可讀取數據
    public abstract ValueTask<bool> WaitToReadAsync(CancellationToken cancellationToken = default);
}

有界通道(Channel)示例

一切已就緒,接下來我們通過示例重點演示有界通道,然後無界通道只不過是通道類型不同,額外增加選項配置而已。首先我們創建消息數據類

public class Message
{
    public Message(string data)
    {
      Data = data;
    }

 public string Data { get; }
}

然後爲方便觀察生產者和消費者數據打印情況,在控制檯中通過不同字體顏色來進行區分,簡單來個日誌類

public static class Logger
{
    private static readonly object obj = new object();
    public static void Log(string text, ConsoleColor color = ConsoleColor.White)
    {
      lock (obj)
      {
        Console.ForegroundColor = color;
        Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd hh:mm:ss.ff}] - {text}");
      }
    }
}

接下來定義生產者發佈數據

public class Producer
{
    private readonly ChannelWriter<Message> _writer;
    private readonly int _msgId;

    public Producer(ChannelWriter<Message> writer, int msgId)
    {
      _writer = writer;
      _msgId = msgId;
    }

    public async Task PublishAsync(Message message, CancellationToken cancellationToken = default)
    {
      await _writer.WriteAsync(message, cancellationToken);

      Logger.Log($"生產者 {_msgId} > 發佈消息 【{message.Data}】", ConsoleColor.Yellow);
    }
}

消費者接收數據,爲模擬演示,延遲50毫秒作爲消息處理時間

public class Consumer
{
    private readonly ChannelReader<Message> _reader;
    private readonly int _msgId;

    public Consumer(ChannelReader<Message> reader, int msgId)
    {
      _reader = reader;
      _msgId = msgId;
    }

    public async Task BeginConsumeAsync(CancellationToken cancellationToken = default)
    {
      Logger.Log($"消費者 {_msgId} > 等待處理消息", ConsoleColor.Green);

      try
      {
        await foreach (var message in _reader.ReadAllAsync(cancellationToken))
        {
          Logger.Log($"消費者 ({_msgId})> 接收消息: 【{message.Data}】", ConsoleColor.Green);

          await Task.Delay(50, cancellationToken);
        }
      }
      catch (Exception ex)
      {
        Logger.Log($"消費者 {_msgId} > 被強迫停止:{ex}", ConsoleColor.Green);
      }

      Logger.Log($"消費者 {_msgId} > 完成處理消息", ConsoleColor.Green);
    }
}

然後定義啓動初始化生產者和消費者任務數量

//啓動指定數量的消費者
private static Task[] StartConsumers(Channel<Message> channel, int consumersCount, CancellationToken cancellationToken)
{
    var consumerTasks = Enumerable.Range(1, consumersCount)
      .Select(i => new Consumer(channel.Reader, i).BeginConsumeAsync(cancellationToken))
      .ToArray();

    return consumerTasks;
}

//啓動指定數量的生產者
private static async Task ProduceAsync(Channel<Message> channel,
 int messagesCount,
 int producersCount,
 CancellationTokenSource tokenSource)
{
    var producers = Enumerable.Range(1, producersCount)
      .Select(i => new Producer(channel.Writer, i))
      .ToArray();

    int index = 0;

    var tasks = Enumerable.Range(1, messagesCount)
      .Select(i =>
      {
        index = ++index % producersCount;
        var producer = producers[index];
        var msg = new Message($"{i}");
        return producer.PublishAsync(msg, tokenSource.Token);
      }).ToArray();

    await Task.WhenAll(tasks);

    Logger.Log("生產者發佈消息完成,結束寫入");
    channel.Writer.Complete();

    Logger.Log("等待消費者處理");
    await channel.Reader.Completion;

    Logger.Log("消費者正在處理");
}

最後一步則是創建通道類型(有界通道),啓動生產者和消費者線程任務並運行

private static async Task Run(int maxMessagesToBuffer, int messagesToSend, int producersCount, int consumersCount)
{
    Logger.Log("*** 開始執行 ***");
    Logger.Log($"生產者數量 #: {producersCount}, 容量大小: {maxMessagesToBuffer}, 消息數量: {messagesToSend}, 消費者數量 #: {consumersCount}");

    var channel = Channel.CreateBounded<Message>(maxMessagesToBuffer);

    var tokenSource = new CancellationTokenSource();
    var cancellationToken = tokenSource.Token;

    var tasks = new List<Task>(StartConsumers(channel, consumersCount, cancellationToken))
    {
      ProduceAsync(channel, messagesToSend, producersCount, tokenSource)
    };

    await Task.WhenAll(tasks);

    Logger.Log("*** 執行完成 ***");
}

接下來我們在主方法中調用上述Run方法,指定有界通道容量爲100,消費數量爲10,生產者和消費者數量各爲1,如下:

static async Task Main(string[] args)
{
    await Run(100, 10, 1, 1);

    Console.ReadLine();
}

 

根據業務上下文我們可指定有界通道滿模式以及其他對應參數

var channel = Channel.CreateBounded<Message>(new BoundedChannelOptions(maxMessagesToBuffer)
{
    FullMode = BoundedChannelFullMode.Wait,
    SingleReader = true,
    SingleWriter = true,
    AllowSynchronousContinuations = false
});

關於無界通道沒啥太多要講解的地方,配置選項如下:

var channel = Channel.CreateUnbounded<Message>(new UnboundedChannelOptions()
{
    SingleReader = true,
    SingleWriter = true,
    AllowSynchronousContinuations = false
});

總結

相比阻塞模型,通道提供異步支持以及靈活配置,更適合在實際業務場景中使用。關於通道大概就講解這麼多,後續我們將分析通道實現原理,更詳細介紹請參看外鏈:https://devblogs.microsoft.com/dotnet/an-introduction-to-system-threading-channels/

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