Как запустить асинхронный метод в отдельном потоке, не дожидаясь его выполнения?

Есть метод:

public async Task StartListeningAsync(CancellationToken stoppingToken)
{
    _listener.Start();

    while (!stoppingToken.IsCancellationRequested)
    {
        var client = await _listener.AcceptTcpClientAsync();
        var thread = new Thread(async () => await ProcessClientAsync(client, stoppingToken));
        thread.Start();
    }
}

Здесь _listener - это объект стандартного класса TcpListener. Задача состоит в том, чтобы обрабатывать отдельного клиента в отдельном потоке. Обработка клиента может длиться очень долго, пока не пропадет соединение (дней 5, например). Метод обработки клиента ProcessClientAsync - асинхронный (можно его, в принципе, сделать синхронным). Мне необходимо запускать этот метод в отдельном потоке. На текущий момент я это делаю через new Thread. Мне сказали, что после первого await внутри метода ProcessClientAsync поток вернет управление наружу, из-за чего поток обработки завершится (обсуждалось это здесь).

Есть еще вариант использовать Task.Run:

_listener.Start();

while (!stoppingToken.IsCancellationRequested)
{
    var client = await _listener.AcceptTcpClientAsync();
    Task.Run(() => ProcessClientAsync(client, stoppingToken), stoppingToken);
}

Но на такой вариант компилятор предупреждает:

Warning CS4014 Because this call is not awaited, execution of the current method continues before the call is completed. Consider applying the 'await' operator to the result of the call.

Вопрос: Как все же будет правильно в моем случае запустить метод обработки клиента ProcessClientAsync в отдельном потоке? Этот метод желательно оставить асинхронным. Если его нельзя оставить асинхронным, то прошу, пожалуйста, объяснить, почему.


Ответы (1 шт):

Автор решения: Pavel Mayorov

Перестаньте думать потоками! Нет, не так...

Перестаньте уже думать потоками!

Вы в своём вопросе исходите из того, что у ваш асинхронный метод выполняется в каком-то конкретном потоке, и вам нужно этот поток сменить. Но истина в том, что большую часть времени ваш асинхронный метод не выполняется ни в одном потоке!

Как устроен типичный асинхронный код обработки клиента? Примерно так:

async Task ProcessClientAsync(TcpClient client, CancellationToken token) {
    while (true) {
        await ReadRequestFrom(client, token);
        ProcessRequest();
        await SendReplyToClient(client, token);
    }
}

Большую часть времени (те самые 5 суток из вопроса) вы просто ждёте поступления данных из сокета. Это самое "ожидание" с точки зрения кода - это просто запись где-то в системных таблицах о том, что когда данные придут - надо уведомить вас. Ни один поток не заблокирован ожиданием. Вы не можете ждать данных от клиента в отдельном потоке потому, что никакого потока нет.

Когда данные придут - выполнение вашего метода в любом случае продолжится в пуле потоков (есть варианты с написанием собственных планировщиков задач - но вряд ли у вас получится написать планировщик лучше системного).

Теперь немного слов о долгих задачах, которыми иногда пугают новичков, и о том, что не следует занимать пул потоков надолго. Так вот, "долгая задача" - это задача, которая занимается активными вычислениями. Ваши 5 дней ни один из потоков пула не занят, поэтому ProcessClientAsync не является долгой задачей. А вот ProcessRequest "долгим" в редких случаях может и оказаться. Если так и произошло - можно сказать об этом системному планировщику задач при помощи вот такой конструкции:

async Task ProcessClientAsync(TcpClient client, CancellationToken token) {
    while (true) {
        await ReadRequestFrom(client, token);
        await Task.Factory.StartNew(() => ProcessRequest(), TaskCreationOptions.LongRunning);
        await SendReplyToClient(client, token);
    }
}

Заметьте: внутри переданного в Task.Factory.StartNew замыкания нет оператора await. Так и должно быть. Если вам понадобилось там внутри написать await - значит, вы ошибочно выбрали "долгую" задачу!


Наконец, про запуск ProcessClientAsync без ожидания. Капитан Очевидность подсказывает, что для запуска без ожидания достаточно ничего не ждать:

    ProcessClientAsync(client, token);

Иногда бывает, что ProcessClientAsync не сразу "уходит" в ожидание данных, а выполняет какие-нибудь "подготовительные" работы, во время которых лучше бы уже начинать ждать следующее подключение. В таком случае следует использовать Task.Run:

    Task.Run(() => ProcessClientAsync(client, token));

Оба варианта допустимы, главное - чтобы не было оператора await.

Теперь почему "ругается" компилятор. Компилятор ругается потому, что

  1. обычно забытый await - это всё-таки ошибка;
  2. задача может "упасть" - и было бы неплохо как-нибудь об этом узнать.

Вторая проблема решается, к примеру, логами. Первая проблема проблемой не является, но компилятор можно "заткнуть" сменив тип возвращаемого значения:

async void ProcessClientAsync(TcpClient client, CancellationToken token) {
    try {
        while (true) {
            await ReadRequestFrom(client, token);
            await Task.Factory.StartNew(() => ProcessRequest(), TaskCreationOptions.LongRunning);
            await SendReplyToClient(client, token);
        }
    }
    catch (Exception ex) {
        // вывод в лог
    }
}

→ Ссылка