前言
無需引入第三方訊息佇列元件,我們如何利用內建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/