How to use Task.WhenAny with ReadLineAsync to get

2019-02-28 17:43发布

问题:

working my way through all that is async / await (coming from threadpools) and I've hit an interesting challenge.

I have a TCP Server running in a WPF application that accepts clients and stores them in a List<> like such:

private List<Client> clients = new List<Client>();
while (running && clientCount <= maxClients)
{
    Client client = new Client(await server.AcceptTcpClientAsync());
    await client.WriteLineAsync("Connected to the Server!");
    clients.Add(client);
    clientCount++;
}

So what I want to do is iterate through the list of my Clients and if any data is received, I want to append it to a textbox. I realize this may not be the best way to achieve this, and I'm open to suggestions, but this is how I currently have it structured.

A button starts the loop and continuously calls and awaits AllReadLineAsync()

private async void btnStartReadLoopClick(object sender, RoutedEventArgs e)
{
    btnStartReadLoop.IsEnabled = false;
    while(server.clientCount > 0)
    {
        string text = await server.AllReadLineAsync();
        txtOutputLog.AppendText("[client] " + text + "\n");
    }
}

which is this function:

public async Task<string> AllReadLineAsync()
{
    var tasklist = new List<Task<string>>();

    foreach (var client in clients)
        tasklist.Add(client.ReadLineAsync());

    while (tasklist.Count > 0)
    {
        Task<string> finishedTask = await Task.WhenAny(tasklist);
        if (finishedTask.Status == TaskStatus.RanToCompletion)
            return await finishedTask;

        tasklist.Remove(finishedTask);
    }

    return "Error: No task finished";
}

This function iterates through the list of clients and creates a List<Tast<string>> of all the ReadLineAsync() tasks.

At any given time, I may only have 1 or 2 clients actually sending data, so I can't WhenAll() and I've tried WhenAny() and WaitAny() without success.

Note to future googlers: WaitAny() is like Wait() and is blocking. Do not do this on a UI thread. Instead use WhenAny() and await it.

So my current implementation kind of works, but I can't figure out this bug where messages will build up from 1 client if the other clients don't send data.

TL;DR: Am I using WhenAny() correctly or is there a better way for me to await ReadLineAsync and pass the result to a textbox?

EDIT: Here's the behavior I'm seeing I typed in this order: Left, Right, Left 2, Right 2, Left 3, Right 3 and it appears as if some messages are being dropped?

EDIT 2: I found the source of the code snippet I copied on the MSDN blog: https://blogs.msdn.microsoft.com/pfxteam/2012/08/02/processing-tasks-as-they-complete/

This code snippet is meant specifically to iterate through a list of tasks ensuring they all are completed. I don't care if tasks are duplicated though so I need to modify the code to always check the entire tasklist instead of removing any tasks.

回答1:

it appears as if some messages are being dropped?

Yes. Because asynchronous work is started when you call their method (e.g., ReadLineAsync). When one of them completes (Task.WhenAny), your code abandons the other tasks. But they keep running - they're still reading from their sockets, and whatever they read is just going to be thrown away.

AFAIK, the behavior is undefined when you start reading from the same socket again - it's possible it may read what's next, or it's possible it may be queued. I do know that you're not supposed to do issue multiple reads from a socket (or any stream) simultaneously.

Sockets are not a perfect match to async, because they can send data at any time. You should use Rx or events instead. It's possible to make async work but it's extremely complex.



回答2:

Alright so I figured out where I was going wrong and why my previous code didn't work.

First off, let's talk about what this code does, and why it doesn't work:

public async Task<string> AllReadLineAsync()
{
    var tasklist = new List<Task<string>>();

    foreach (var client in clients)
        tasklist.Add(client.ReadLineAsync());

    while (tasklist.Count > 0)
    {
        Task<string> finishedTask = await Task.WhenAny(tasklist);
        if (finishedTask.Status == TaskStatus.RanToCompletion)
            return await finishedTask;

        tasklist.Remove(finishedTask);
    }

    return "Error: No task finished";
}

1) Creates a list of await ReadLineAsync()

2) While the size of that list is greater than 0, we wait for await any of the ReadLineAsync functions.

3) When we hit a Task that has finished, we return it's string, and exit the function

4) Any remaining ReadLineAsync functions that did not finish are still running, but we lost the reference to their instance.

5) This function loops, calling AllReadAsync() immediately after it finishes.

6) This causes us to try and access the StreamReady while it is still being awaited from step 4 - Thus throwing an excpetion.


Handling Multiple TcpClients with an Async TCP Server

Because of the structure of this, I could not come up with a way to use WhenAny() in my application. Instead I added this function to my Client Class:

public async Task<string> CheckForDataAsync()
{
    if (!stream.DataAvailable)
    {
        await Task.Delay(5);
        return "";
    }

    return await reader.ReadLineAsync();
}

Instead of awaiting ReadLineAsync(), we instead access the NetworkStream from the TcpClient, and we check if there is data available, if(!stream.DataAvailable), if there is not, we return early with an empty string, else we await ReadLineAsync() because we know we have incoming data, and we expect to receive the whole line.

We then replace the first function I talked about, AllReadLineAsync() With the following:

public async Task<string> AllReadLineAsync()
{
    string data = "", packet = "";
    foreach (var client in clients)
    {
        data = await client.CheckForDataAsync();

        if (data != string.Empty)
            packet += string.Format($"[client] {data}\n");   
    }

    return packet;
}

Which is even simpler than the previous way I was trying. This now iterates through all of our clients in a for loop, and calls the CheckForDataAsync() function on each client. Since this functions returns early instead of infinitely awaiting a full ReadLineAsync() it does not continue to run in the background after the AllReadLineAysnc() function ends.

After we finish looping through all of our clients, we take our string packet and return it to the UI context, where we can then add our data to a text box as such:

private async void RecvData(object sender, RoutedEventArgs e)
{
    while(server.hasClient)
    {
        string text = await server.AllReadLineAsync();
        txtOutputLog.AppendText(text);
    }
}

And that's it. That's how I'm handling multiple TcpClients from within a WPF application.