![](/img/trans.png)
[英]CancellationTokenSource does not cancel task after given timeout
[英]Channels with CancellationTokenSource with timeout memory leak after dispose
完整的可重現代碼在 github 上,memory 將在啟動可執行文件后很快飛速發展。 代碼主要位於AsyncBlockingQueue.cs
class 中。
以下代碼實現了一個簡單的異步“阻塞”隊列:
public async Task<T> DequeueAsync(
int timeoutInMs = -1,
CancellationToken cancellationToken = default)
{
try
{
using (CancellationTokenSource cts = this.GetCancellationTokenSource(timeoutInMs, cancellationToken))
{
T value = await this._channel.Reader.ReadAsync(cts?.Token ?? cancellationToken).ConfigureAwait(false);
return value;
}
}
catch (ChannelClosedException cce)
{
await Console.Error.WriteLineAsync("Channel is closed.");
throw new ObjectDisposedException("Queue is disposed");
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
await Console.Error.WriteLineAsync("Dequeue failed.");
throw;
}
}
private CancellationTokenSource GetCancellationTokenSource(
int timeoutInMs,
CancellationToken cancellationToken)
{
if (timeoutInMs <= 0)
{
return null;
}
CancellationTokenSource cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(TimeSpan.FromMilliseconds(timeoutInMs));
return cts;
}
以這種方式使用時,它有 memory 泄漏:
try
{
string message = await this._inputQueue.DequeueAsync(10,cancellationToken).ConfigureAwait(false);
}
catch(OperationCanceledException){
// timeout
}
更新
從評論:
有一個處理器可以批量處理消息。 當有足夠的消息或時間到時它開始處理,這就是超時取消出現的地方
這意味着真正需要的是一種按計數和周期對消息進行批處理的方法。 做任何一個都相對容易。
此方法按計數進行批處理。 該方法將消息添加到batch
列表直到達到限制,將數據發送到下游並清除列表:
static ChannelReader<Message[]> BatchByCount(this ChannelReader<Message> input, int count, CancellationToken token=default)
{
var channel=Channel.CreateUnbounded();
var writer=channel.Writer;
_ = Task.Run(async ()=>{
var batch=new List<Message>(count);
await foreach(var msg in input.ReadAllAsync(token))
{
batch.Add(msg);
if(batch.Count==count)
{
await writer.WriteAsync(batch.ToArray());
batch.Clear();
}
}
},token)
.ContinueWith(t=>writer.TryComplete(t.Exception));
return channel;
}
按周期分批的方法更復雜,因為定時器可以在收到消息的同時觸發。 Interlocked.Exchange
用一個新的batch
列表,並將批處理的數據發送到下游。 :
static ChannelReader<Message[]> BatchByPeriod(this ChannelReader<Message> input, TimeSpan period, CancellationToken token=default)
{
var channel=Channel.CreateUnbounded();
var writer=channel.Writer;
var batch=new List<Message>();
Timer t=new Timer(async obj =>{
var data=Interlocked.Exchange(ref batch,new List<Message>());
writer.WriteAsync(data.ToArray());
},null,TimeSpan.Zero,period);
_ = Task.Run(async ()=>{
await foreach(var msg in input.ReadAllAsync(token))
{
batch.Add(msg);
}
},token)
.ContinueWith(t=>{
timer.Dispose();
writer.TryComplete(t.Exception);
});
return channel;
}
兩者兼得——我仍在努力。 問題是計數和計時器到期可以同時發生。 最壞的情況, lock(batch)
可用於確保只有線程或循環可以將數據發送到下游
原始答案
正確使用時通道不會泄漏 - 就像任何其他容器一樣。 Channel 不是異步隊列,也絕對不是阻塞隊列。 這是一個非常不同的結構,具有完全不同的習語。 它是一個使用隊列的高級容器。 有一個很好的理由有單獨的 ChannelReader 和 ChannelWriter 類。
典型的場景是讓發布者創建並擁有頻道。 只有發布者可以寫入該通道並在其上調用Complete()
。 Channel
沒有實現IDisposable
所以它不能被釋放。 發布者僅向訂閱者提供ChannelReader
。
訂閱者只能看到一個ChannelReader
並從中讀取,直到它完成。 通過使用ReadAllAsync
,訂閱者可以繼續從 ChannelReader 讀取,直到完成。
這是一個典型的例子:
ChannelReader<Message> Producer(CancellationToken token=default)
{
var channel=Channel.CreateUnbounded<Message>();
var writer=channel.Writer;
//Create the actual "publisher" worker
_ = Task.Run(async ()=>{
for(int i=0;i<100;i++)
{
//Check for cancellation
if(token.IsCancellationRequested)
{
return;
}
//Simulate some work
await Task.Delay(100);
await writer.WriteAsync(new Message(...));
}
} ,token)
//Complete and propagate any exceptions
.ContinueWith(t=>writer.TryComplete(t.Exception));
//This casts to a ChannelReader
return channel;
}
訂閱者只需要一個ChannelReader
即可工作。 通過使用ChannelReader.ReadAllAsync訂閱者只需要await foreach
來處理消息:
async Task Subscriber(ChannelReader<Message> input,CancellationToken token=default)
{
await foreach(var msg in input.ReadAllAsync(token))
{
//Use the message
}
}
訂閱者可以通過返回 ChannelReader 來生成自己的消息。 這就是事情變得非常有趣的地方,因為Subscriber
方法成為了一系列鏈接步驟中的一個步驟。 如果我們將方法轉換為ChannelReader
上的擴展方法,我們可以輕松創建整個管道。
讓我們生成一些數字:
ChannelReader<int> Generate(int nums,CancellationToken token=default)
{
var channel=Channel.CreateBounded<int>(10);
var writer=channel.Writer;
//Create the actual "publisher" worker
_ = Task.Run(async ()=>{
for(int i=0;i<nums;i++)
{
//Check for cancellation
if(token.IsCancellationRequested)
{
return;
}
await writer.WriteAsync(i*7);
await Task.Delay(100);
}
} ,token)
//Complete and propagate any exceptions
.ContinueWith(t=>writer.TryComplete(t.Exception));
//This casts to a ChannelReader
return channel;
}
然后將它們加倍並平方:
ChannelReader<double> Double(this ChannelReader<int> input,CancellationToken token=default)
{
var channel=Channel.CreateBounded<double>(10);
var writer=channel.Writer;
//Create the actual "publisher" worker
_ = Task.Run(async ()=>{
await foreach(var msg in input.ReadAllAsync(token))
{
await writer.WriteAsync(2.0*msg);
}
} ,token)
//Complete and propagate any exceptions
.ContinueWith(t=>writer.TryComplete(t.Exception));
return channel;
}
ChannelReader<double> Root(this ChannelReader<double> input,CancellationToken token=default)
{
var channel=Channel.CreateBounded<double>(10);
var writer=channel.Writer;
//Create the actual "publisher" worker
_ = Task.Run(async ()=>{
await foreach(var msg in input.ReadAllAsync(token))
{
await writer.WriteAsync(Math.Sqrt(msg));
}
} ,token)
//Complete and propagate any exceptions
.ContinueWith(t=>writer.TryComplete(t.Exception));
return channel;
}
最后打印它們
async Task Print(this ChannelReader<double> input,CancellationToken token=default)
{
await foreach(var msg in input.ReadAllAsync(token))
{
Console.WriteLine(msg);
}
}
現在我們可以建立一個管道
await Generate(100)
.Double()
.Square()
.Print();
並在所有步驟中添加取消令牌:
using var cts=new CancellationTokenSource();
await Generate(100,cts.Token)
.Double(cts.Token)
.Square(cts.Token)
.Print(cts.Token);
如果一個步驟產生的消息比它們長時間消耗的速度快,則 Memory 的使用量可能會增加。 這很容易通過使用有界而不是無界通道來處理。 這樣,如果一個方法太慢,所有以前的方法都必須在發布新數據之前等待。
我能夠重現您正在觀察的問題。 恕我直言,這顯然是Channels庫中的一個缺陷。 這是我的復制品:
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Threading.Tasks.Dataflow;
public static class Program
{
public static async Task Main()
{
var channel = Channel.CreateUnbounded<int>();
var bufferBlock = new BufferBlock<int>();
var asyncCollection = new Nito.AsyncEx.AsyncCollection<int>();
var mem0 = GC.GetTotalMemory(true);
int timeouts = 0;
for (int i = 0; i < 10; i++)
{
var stopwatch = Stopwatch.StartNew();
while (stopwatch.ElapsedMilliseconds < 500)
{
using var cts = new CancellationTokenSource(1);
try
{
await channel.Reader.ReadAsync(cts.Token);
//await bufferBlock.ReceiveAsync(cts.Token);
//await asyncCollection.TakeAsync(cts.Token);
}
catch (OperationCanceledException) { timeouts++; }
}
var mem1 = GC.GetTotalMemory(true);
Console.WriteLine($"{i + 1,2}) Timeouts: {timeouts,5:#,0},"
+ $" Allocated: {mem1 - mem0:#,0} bytes");
}
}
}
Output:
1) Timeouts: 124, Allocated: 175,664 bytes
2) Timeouts: 250, Allocated: 269,720 bytes
3) Timeouts: 376, Allocated: 362,544 bytes
4) Timeouts: 502, Allocated: 453,264 bytes
5) Timeouts: 628, Allocated: 548,080 bytes
6) Timeouts: 754, Allocated: 638,800 bytes
7) Timeouts: 880, Allocated: 729,584 bytes
8) Timeouts: 1,006, Allocated: 820,304 bytes
9) Timeouts: 1,132, Allocated: 919,216 bytes
10) Timeouts: 1,258, Allocated: 1,011,928 bytes
每次操作泄漏大約 800 個字節,這非常令人討厭。 每次在通道中寫入新值時,都會回收 memory,因此對於繁忙的通道,這個設計缺陷應該不是問題。 但是對於偶爾接收價值的渠道來說,這可能是一個阻礙。
還有其他可用的異步隊列實現,它們不會遇到同樣的問題。 您可以嘗試評論await channel.Reader.ReadAsync(cts.Token);
行並取消注釋以下兩行中的任何一行。 您將看到TPL Dataflow庫中的 BufferBlock BufferBlock<T>
AsyncCollection<T>
和Nito.AsyncEx.Coordination package 中的 AsyncCollection<T> 都允許從隊列中異步檢索超時,而不會發生 memory 泄漏。
我全神貫注於實際問題的技術細節,我忘記了問題已經幾乎開箱即用地解決了。
從評論看來,實際問題是:
有一個處理器可以批量處理消息。 當有足夠的消息或時間到時它開始處理,這就是超時取消出現的地方
這是由ReactiveX.NET的Buffer運算符提供的,該運算符由創建System.Linq.Async的同一團隊構建:
ChannelReader<Message> reader=_channel;
IAsyncEnumerable<IList<Message>> batchItems = reader.ReadAllAsync(token)
.ToObservable()
.Buffer(TimeSpan.FromSeconds(30), 5)
.ToAsyncEnumerable();
await foreach(var batch in batchItems.WithCancellation(token))
{
....
}
這些調用可以轉換為擴展方法,因此問題的 class 可以具有DequeueAsync
或GetWorkItemsAsync
方法,而不是BufferAsync
:
public IAsyncEnumerable<T[]> BufferAsync(
TimeSpan timeSpan,
int count,
CancellationToken cancellationToken = default)
{
return _channel.Reader.BufferAsync(timeSpan,count,cancellationToken);
}
ToObservable
和ToAsyncEnumerable
由System.Linq.Async提供,並在 ReactiveX.NET 使用的接口IAsyncEnumerable
和IObservable
之間進行轉換。
緩沖區由System.Reactive提供,並按計數或周期逐項緩沖,甚至允許重疊序列。
雖然 LINQ 和 LINQ 為 Async 提供了對對象的查詢運算符,但 Rx.NET 對基於時間的事件流也是如此。 可以隨時間聚合、按時間緩沖事件、限制它們等。Buffer 的(非官方)文檔頁面中的示例展示了如何創建重疊序列(例如滑動窗口)。 同一頁面顯示了如何使用Sample
或Throttle
通過僅傳播一段時間內的最后一個事件來限制快速事件流。
Rx 使用推送 model(新事件推送給訂閱者),而 IAsyncEnumerable 和 IEnumerable 一樣,使用拉取 model。 ToAsyncEnumerable()
將緩存項目直到它們被請求,如果沒有人在聽,這可能會導致問題。
使用這些方法,甚至可以創建擴展方法來緩沖或限制發布者:
//Returns all items in a period
public static IAsyncEnumerable<IList<T>> BufferAsync<T>(
this ChannelReader<T> reader,
TimeSpan timeSpan,
int count,
CancellationToken token = default)
{
return reader.ReadAllAsync(token)
.ToObservable()
.Buffer(timeSpan, count)
.ToAsyncEnumerable();
}
//Return the latest item in a period
public static IAsyncEnumerable<T> SampleAsync<T>(
this ChannelReader<T> reader,
TimeSpan timeSpan,
CancellationToken token = default)
{
return reader.ReadAllAsync(token)
.ToObservable()
.Sample(timeSpan)
.ToAsyncEnumerable();
}
聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.