簡體   English   中英

具有 CancellationTokenSource 和超時 memory 的通道在處理后泄漏

[英]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.NETBuffer運算符提供的,該運算符由創建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 可以具有DequeueAsyncGetWorkItemsAsync方法,而不是BufferAsync

public IAsyncEnumerable<T[]> BufferAsync(
            TimeSpan timeSpan,
            int count,
            CancellationToken cancellationToken = default)
{
    return _channel.Reader.BufferAsync(timeSpan,count,cancellationToken);
}

ToObservableToAsyncEnumerableSystem.Linq.Async提供,並在 ReactiveX.NET 使用的接口IAsyncEnumerableIObservable之間進行轉換。

緩沖區System.Reactive提供,並按計數或周期逐項緩沖,甚至允許重疊序列。

雖然 LINQ 和 LINQ 為 Async 提供了對對象的查詢運算符,但 Rx.NET 對基於時間的事件流也是如此。 可以隨時間聚合、按時間緩沖事件、限制它們等。Buffer 的(非官方)文檔頁面中的示例展示了如何創建重疊序列(例如滑動窗口)。 同一頁面顯示了如何使用SampleThrottle通過僅傳播一段時間內的最后一個事件來限制快速事件流。

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.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM