【问题标题】:Exiting from a BackgroundWorker从 BackgroundWorker 退出
【发布时间】:2021-12-06 03:07:02
【问题描述】:

以下是关于我要做什么的简要说明:

我有 2 个按钮:Button_Auto 启动 backgroundWorker_AutoButton_Manual 停止(应该停止)正在运行的 backgroundWorker_Auto 并启动另一个按钮 backgroundWorker_Manual。基本上,按钮应该允许用户在我的应用程序中的两种操作模式之间切换。自动和手动

        private Button_Auto_Click(object sender, EventArgs e)
        {
             if (!backgroundWorker_Auto.IsBusy)
                 backgroundWorker_Auto.RunWorkerAsync();
        }      


        private Button_Manual_Click(object sender, EventArgs e)
        {
          //some code to stop backgroundWorker_Auto..

             if (!backgroundWorker_Manual.IsBusy)
                 backgroundWorker_Manual.RunWorkerAsync();
        }      

backgroundWorker_Auto 只是一个连接到服务器的 TCP 客户端,从另一个应用程序对服务器的 API 调用接收数据。

我见过很多使用迭代器取消后台工作者的解决方案,它会在每次迭代时检查CancellationPending 属性。但是,在我下面的代码中,backgroundworker 只是等待来自 TCP 服务器的数据。

        public static TcpClient client;

        private void backgroundWorker_Auto_DoWork(object sender, System.ComponentModel.DoWorkEventArgs e)
        {

            try
            {              
                NetworkStream nwStream = client.GetStream();
                while (client.Connected)
                {
                    
                    byte[] bytesToRead = new byte[client.ReceiveBufferSize];
                    int bytesRead = nwStream.Read(bytesToRead, 0, client.ReceiveBufferSize);  //CODE WAITS HERE!!
                    String responseData = String.Empty;
                    responseData = Encoding.ASCII.GetString(bytesToRead, 0, bytesRead);
                    switch (responseData)
                          {
                            case "1":
                            //Do something;
                            break;
                                        
                            case "2":
                            //Do some other thing;
                            break;
                          }
                }
             }

            catch (Exception ex)
             {
                MessageBox.Show(ex.Message)
             }                                                                                        
        }

问题是当backgroundWorker_Auto 启动时,它会在int bytesRead 行等待从服务器接收数据。收到后,它会执行以下功能并返回与上述相同的侦听状态。因此,即使我从 Button_Manual 触发 CancelAsync 并将 while 循环条件更改为 backgroundWorker_Auto.CancellationPending,除非客户端收到数据,否则不会对其进行检查。

而且由于backgroundWorker_Auto 没有停止,我将无法再次启动它,即无法在自动和手动之间切换。

如何在这种情况下检查CancellationPending 条件或正确停止backgroundWorker_Auto

【问题讨论】:

  • 使用ReadAsync 允许您使用取消令牌中止读取操作;同步版本没有这样做的好方法。您必须用异步方法替换后台工作程序,但现在这并不太复杂。
  • 不要首先使用 BGW。该类已过时,10 年前完全被 async/await、Task.Run 和 IProgress 取代。在这种情况下,TcpClient 和 NetworkStream 都提供异步方法。您可以用一个方法替换所有这些复杂的代码,该方法使用您放入 DoWorkStream.ReadAsync 而不是 Read 等的内容
  • client.Close(); 完成工作,从按钮的 Click 事件中调用它。之后再次重新连接它,无论如何它都属于工人。 try/except 异常报错,属于RunWorkerCompleted事件处理函数,不报ObjectDisposedException。

标签: c# .net multithreading winforms backgroundworker


【解决方案1】:

不要一开始就使用 BackgroundWorker。该类已过时,几乎在 10 年前被async/awaitTask.RunIProgress<T> 完全取代。有很多对async/await 微不足道的事情对 BGW 来说非常困难。这包括取消和组合多个异步操作。

在这种情况下,BGW 看起来可以被一个单独的异步方法替换,该方法执行 DoWork 所做的事情:

async Task ListenAuto(TcpClient client,CancellationToken token=default)
{
    try
        {              
            using var nwStream = client.GetStream();
            var bytesToRead = new byte[client.ReceiveBufferSize];
            while (client.Connected && !token.IsCancellationRequested)
            {
                int bytesRead = await nwStream.ReadAsync(bytesToRead, 0, 
                                         client.ReceiveBufferSize,token);  //Not blocking
                var responseData = Encoding.ASCII.GetString(bytesToRead, 0, bytesRead);
                switch (responseData)
                      {
                        case "1":
                        await Task.Run(()=>DoSomething1());
                        
                        break;
                                    
                        case "2":
                        await Task.Run(()=>DoSomething2());
                        break;
                      }
            }
         }
         catch(OperationCanceledException)
         {
             //Cancelled, no need to show anything
         }
         catch (Exception ex)
         {
            MessageBox.Show(ex.Message)
         }     
}

这可以改进和简化:

  1. 在worker方法本身中创建TcpClient,这样就可以安全地释放了
  2. 使用 StreamReader 而不是手动解码字节。
  3. 只读取预期的字符,或者有办法处理多条消息。如果服务器发送了 2 个或三个连续的数字,例如 1、3、5,则当前代码会将它们读取为135

正如最近有人说的:Almost all Sockets problems are framing problems

在这种情况下,我假设每个字符都是单独的消息。代码可以简化为:

async Task ListenAuto(IPAddress address,int port,CancellationToken token=default)
{
    try
        {        
            using var client=new TcpClient(endpoint);
            await client.ConnedtAsync(address,port,token);

            using var nwStream = client.GetStream();
            using var reader=new StreamReader(nwStream,Encoding.ASCII);

            var chars = new char[client.ReceiveBufferSize];
            while (client.Connected && !token.IsCancellationRequested)
            {
                int charsRead = await reader.ReadAsync(chars, 0,chars.Length,token);  //Not blocking
                for(int i=0;i<charsRead;i++)
                {
                    switch (chars[i])
                    {
                     ...
                    }
                 } 
            }
         }
         catch(OperationCanceledException)
         {
             //Cancelled, no need to show anything
         }
         catch (Exception ex)
         {
            MessageBox.Show(ex.Message)
         }     
}

CancellationToken 实例由CancellationTokenSource 类提供。这个类只能取消一次,也就是说每次都需要创建一个新的:

    CancellationTokenSource _autoCancel;
    CancellationTokenSource _manualCancel;

    private async void Button_Auto_Click(object sender, EventArgs e)
    {
        //Just in case it's null
        _manualCancel?.Cancel();

        _autoCancel=new CancellationTokenSource();
        await ListenAuto(server,port,_autoCancel.Token);
    }   

    private async void Button_Manual_Click(object sender, EventArgs e)
    {
        //Just in case it's null
        _autoCancel?.Cancel();

        _manualCancel=new CancellationTokenSource();
        await ListenManual(server,port,_manualCancel.Token);
    }   

监听和处理分开

另一个改进是分离轮询和处理代码,尤其是在两种情况下处理相同的情况下。 ListenAutoListenManual 不会同时进行侦听和处理,而只会检查消息并将它们发布给异步处理它们的工作人员。有几种方法可以实现这样的工作者。

  • 在 .NET Core 和 .NET Framework 中使用 ActionBlock
  • 在两者中都使用通道
  • 在 .NET Core 3 及更高版本中使用 IAsyncEnumerable

假设worker是一个ActionBlock:

ActionBlock _block=new ActionBlock(msg=>ProcessMsg(msg));

async Task ProcessMsg(char msg)
{
    switch(msg)
    {
        case '1':
        ...
    }
}

ActionBlock 使用一个或多个任务(默认为 1)来处理按顺序发送到其输入缓冲区的所有消息。默认情况下,可以缓冲的项目数量没有限制。

在这种情况下,ListenAuto 方法将更改为:

async Task ListenAuto(IPAddress address,int port,CancellationToken token=default)
{
    try
        {        
            using var client=new TcpClient(endpoint);
            await client.ConnedtAsync(address,port,token);

            using var nwStream = client.GetStream();
            using var reader=new StreamReader(nwStream,Encoding.ASCII);

            var chars = new char[client.ReceiveBufferSize];
            while (client.Connected && !token.IsCancellationRequested)
            {
                int charsRead = await reader.ReadAsync(chars, 0,chars.Length,token);  
                
                for(int i=0;i<charsRead;i++)
                {
                    _block.PostAsync(chars[i]);
                } 
            }
         }
         catch(OperationCanceledException)
         {
             //Cancelled, no need to show anything
         }
         catch (Exception ex)
         {
            MessageBox.Show(ex.Message)
         }     
}

一旦创建了ActionBlock,它将继续处理消息。当我们想停止它时,我们调用Complete() 并等待所有待处理的消息通过Completion 任务得到处理:

public async void StopProcessing_Click()
{
    _manualCancel?.Cancel();
    _autoCancel?.Cancel();

    _block.Complete();
    await _block.Completion;
}

【讨论】:

    【解决方案2】:

    您的 Auto 方法应该在单独的线程上执行,以便循环可以随时中断单独的线程。

    为简单起见,您可能应该使用 CancellationToken 并使用 ReadAsync

    因此,在每个事件中,您都可以将 RunWorkAsync(object o) 与作为 CancellationToken 的对象一起使用。

    您可能可以使用此示例控制台程序进行测试:

        class Program
        {
            static void Main(string[] args)
            {
                var autoctsource = new CancellationTokenSource();
                var autoct = autoctsource.Token;
    
                var manualctsource = new CancellationTokenSource();
                var manualct = manualctsource.Token;
    
                Task auto = null;
                Task manual = null;
    
                auto = new Task(async () =>
                {
                    if (manual.Status == TaskStatus.Running)
                    {
                        manualctsource.Cancel();
                    }
    
                    var tcp = new TcpClient();
                    while (tcp.Connected)
                    {
                        var stream = tcp.GetStream();
                        byte[] bytesToRead = new byte[tcp.ReceiveBufferSize];
                        int bytesRead = await stream.ReadAsync(bytesToRead.AsMemory(0, tcp.ReceiveBufferSize), autoct);
                        //TCP code
                    }
    
                }, autoct);
    
                manual = new Task(() =>
                {
                    if (auto.Status == TaskStatus.Running)
                    {
                        autoctsource.Cancel();
                    }
                    Console.WriteLine(auto.Status);
                    
                    while(!manualct.IsCancellationRequested)
                    {
                        //Manual code loop
                    }
                }, manualct);
    
                auto.Start();
                Task.Delay(5000);
                manual.Start();
    
            }
        }
    

    【讨论】:

    • 该描述具有误导性(您没有在单独的线程上运行东西,而且您当然不会中断另一个线程 - 无论如何这将是一个可怕的想法),但您接近体面的解决方案。使用new Task 是个坏主意——要么使用Task.Run,要么只调用一个异步方法没有 await 并存储返回的任务。 Read 连接关闭时返回零,不要检查Connected。 CancellationTokens 对于像这样的启停没有用——你只能切换一次。依赖任务状态也没有多大意义。
    • 绝对没有理由使用冷任务来手动启动它们。任务不是线程。调用Task.Start 不会开始执行,它会将作业提交给TaskScheduler 由线程池线程执行。
    • 你所有的 cmets 显然都很有用。这只是一个例子,不是一个完整的解决方案,显然存在一些问题。我会从Two-way signaling 亲自启发以实现所要求的。但这是一个偏好问题。
    【解决方案3】:

    您可以将NetworkStream.ReadTimeout 属性设置为某个值(例如,5000 毫秒/5 秒)。如果服务器在 5 秒内没有响应 - 处理超时异常并进入新的循环迭代。然后一次又一次。每 5 秒循环将检查 BackgroundWorker 是否被取消,如果是 - 循环将被中断。您可以配置超时值,但请记住,NetworkStream.Read 将在新迭代之前等待该时间,这将检查 BGW 取消。

    类似的东西:

    private TcpClient client;
    
    private void ButtonRunWorker_Click(object sender, EventArgs e)
    {
        if (!backgroundWorker_Auto.IsBusy)
            backgroundWorker_Auto.RunWorkerAsync();
    }
    
    private void BackgroundWorker_Auto_DoWork(object sender, DoWorkEventArgs e)
    {
        try
        {
            client = new TcpClient(yourServerIP, yourServerPort);
        }
        catch (Exception ex) // If failed to connect or smth...
        {
            MessageBox.Show(ex.Message);
            // If client failed to initialize - no sense to continue, so close it and return.
            client?.Close();
            client?.Dispose();
            return;
        }
    
        using (NetworkStream ns = client.GetStream())
        {
            // Set time interval to wait for server response
            ns.ReadTimeout = 5000;
    
            while (client.Connected)
            {
                // If BackgroundWorker was cancelled - break loop
                if (backgroundWorker_Auto.CancellationPending)
                {
                    e.Cancel = true;
                    break;
                }
    
                byte[] bytesToRead = new byte[client.ReceiveBufferSize];
                int bytesRead = 0;
    
                // Wrap read attempt into try
                do
                {
                    try
                    {
                        // Code still waits here, but now only for 5 sec
                        bytesRead = ns.Read(bytesToRead, 0, client.ReceiveBufferSize);
                    }
                    catch (Exception ex)
                    {
                        // Handle timeout exception (but not with MessageBox). Maybe with some logger.
                    }
                } while (ns.DataAvailable); // Read while data from server available
    
                // Process response
                string responseData = Encoding.ASCII.GetString(bytesToRead, 0, bytesRead);
                switch (responseData)
                {
                    case "1":
                        //Do something;
                        break;
                    case "2":
                        //Do some other thing;
                        break;
                }
            }
        }
     
        // Close TCP Client properly
        client?.Close();
        client?.Dispose();
    }
    
    private void ButtonStopWorker_Click(object sender, EventArgs e)
    {
        // Cancel BackgroundWorker
        backgroundWorker_Auto.CancelAsync();
    }
    

    编辑。我不建议在这里使用NetworkStream.ReadAsync,因为一旦你开始await 它-RunWorkerCompleted 会在BackgroundWorker 完成时触发。 ReadAsync 可能可用,如果运行 TcpClientTask.RunCancellationTokens。

    【讨论】:

    • I do not recommend to use NetworkStream.ReadAsync 为什么?真正的问题是使用过时的 BGW。所有这些代码都可以替换,通过使用单个异步方法来简化DoWork 所做的工作,而改为使用异步方法。很有可能根本不需要Task.Run
    • 同意。 Tasks 更简单且可用于此类目的。但是由于 OP 使用 BGW - 我不建议将 async-await 放在 DoWork 上。如果你想使用async-await - 使用Tasks,而不是BGW。
    • 所以不要用 BGW 发布答案。这才是真正的问题。在任何情况下,DoWork 都是一个事件,因此您可以使用async voidawait 来避免浪费线程,即使它是后台线程
    • @PanagiotisKanavos 你能分享一个例子吗?
    • 您需要TcpClient 中的TcpClient 示例而不是BGW?
    【解决方案4】:

    在读取 NetworkStream 之前检查 DataAvailable,这样当没有要读取的内容时它不会阻塞线程。

    if(nwStream.CanRead && nwStream.DataAvailable)
    {    
        int bytesRead = nwStream.Read(bytesToRead, 0, client.ReceiveBufferSize);
        ...
    

    【讨论】:

    • 好提示!!通过一些修改,我能够实现我的目标
    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-11-17
    • 2012-04-08
    • 2020-01-19
    • 2013-07-29
    • 1970-01-01
    相关资源
    最近更新 更多