异步/等待问题 - 当等待的操作结束后会发生什么?

3

我认为我已经阅读了大约20篇关于async/await的文章,多个stackoverflow上的问题,但我对它的工作原理仍有很多疑惑,特别是在涉及到多线程和单线程使用时。

我编写了以下代码来测试一个场景:

static async Task Main(string[] args)
        {
            var longTask = DoSomethingLong();

            for (int i = 0; i < 1000; i++)
                Console.Write(".");

            await longTask;
        }

        static async Task DoSomethingLong()
        {
            await Task.Delay(10);
            for (int i = 0; i < 1000; i++)
                Console.Write("<");
        }

当我在DoSomethingLong方法中使用delay来阻塞执行时,我明确地允许我的Main继续将点写入控制台。我可以看到,当delay结束后,点和<符号的写入开始相互干扰。

我只是无法想象:是两个线程同时执行这项工作吗?一个线程写入点,另一个线程写入其他字符?还是某种方式下只有一个线程在上下文之间切换?我会说这是第一种情况,但我仍然不确定。当使用async/await时,什么时候可以期望创建一个额外的线程?这对我来说仍然不清楚。

此外,执行Mai、达到await并回来写入点的线程始终是相同的线程吗?


在我看来,这是两个线程。第一个线程(主线程?)在调用异步函数执行时启动第二个线程。 - Pomme De Terre
你确定点号会干扰 < 吗?从逻辑上讲(以及运行时),它们不会。 - Haytam
1
也许每个人都可以停止坚称async/await依赖于线程,这将是一个好的开始。这里有一个很好的描述https://dev59.com/jloU5IYBdhLWcg3we24R 还有这里https://blog.stephencleary.com/2013/11/there-is-no-thread.html - DavidG
2
这些解释了单个代码路径运行并等待某些操作的情况。它们没有解释第一个路径在继续自身之前不等待第二个路径完成的情况。这些情况非常不同。 - Sami Kuhmonen
请注意,以下答案仅适用于控制台应用程序。 - H H
显示剩余4条评论
2个回答

2
var longTask = DoSomethingLong();

这行代码创建了一个“任务”(Task)。与使用“Task.Run()”创建的任务不同,这是一种基于状态机的任务,由C#编译器自动生成。以下是编译器生成的代码(从sharplab.io复制):
using System;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Security;
using System.Security.Permissions;
using System.Threading.Tasks;

[assembly: CompilationRelaxations(8)]
[assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)]
[assembly: Debuggable(DebuggableAttribute.DebuggingModes.Default
    | DebuggableAttribute.DebuggingModes.DisableOptimizations
    | DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints
    | DebuggableAttribute.DebuggingModes.EnableEditAndContinue)]
[assembly: SecurityPermission(SecurityAction.RequestMinimum, SkipVerification = true)]
[assembly: AssemblyVersion("0.0.0.0")]
[module: UnverifiableCode]
public class Program
{
    [CompilerGenerated]
    private sealed class <DoSomethingLong>d__0 : IAsyncStateMachine
    {
        public int <>1__state;

        public AsyncTaskMethodBuilder <>t__builder;

        private int <i>5__1;

        private TaskAwaiter <>u__1;

        private void MoveNext()
        {
            int num = <>1__state;
            try
            {
                TaskAwaiter awaiter;
                if (num != 0)
                {
                    awaiter = Task.Delay(10).GetAwaiter();
                    if (!awaiter.IsCompleted)
                    {
                        num = (<>1__state = 0);
                        <>u__1 = awaiter;
                        <DoSomethingLong>d__0 stateMachine = this;
                        <>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
                        return;
                    }
                }
                else
                {
                    awaiter = <>u__1;
                    <>u__1 = default(TaskAwaiter);
                    num = (<>1__state = -1);
                }
                awaiter.GetResult();
                <i>5__1 = 0;
                while (<i>5__1 < 1000)
                {
                    Console.Write("<");
                    <i>5__1++;
                }
            }
            catch (Exception exception)
            {
                <>1__state = -2;
                <>t__builder.SetException(exception);
                return;
            }
            <>1__state = -2;
            <>t__builder.SetResult();
        }

        void IAsyncStateMachine.MoveNext()
        {
            // ILSpy generated this explicit interface implementation from
            // .override directive in MoveNext
            this.MoveNext();
        }

        [DebuggerHidden]
        private void SetStateMachine(IAsyncStateMachine stateMachine)
        {
        }

        void IAsyncStateMachine.SetStateMachine(IAsyncStateMachine stateMachine)
        {
            // ILSpy generated this explicit interface implementation from
            // .override directive in SetStateMachine
            this.SetStateMachine(stateMachine);
        }
    }

    [AsyncStateMachine(typeof(<DoSomethingLong>d__0))]
    [DebuggerStepThrough]
    private static Task DoSomethingLong()
    {
        <DoSomethingLong>d__0 stateMachine = new <DoSomethingLong>d__0();
        stateMachine.<>t__builder = AsyncTaskMethodBuilder.Create();
        stateMachine.<>1__state = -1;
        AsyncTaskMethodBuilder <>t__builder = stateMachine.<>t__builder;
        <>t__builder.Start(ref stateMachine);
        return stateMachine.<>t__builder.Task;
    }
}

实际上发生的是,DoSomethingLong方法的第一部分是同步执行的。这是第一个await之前的部分。 在这种特定情况下,在第一个await之前没有任何内容,因此对DoSomethingLong的调用几乎立即返回。它只需要注册一个延续与其余代码一起在线程池线程中运行,等待10毫秒后。在调用返回后,该代码在主线程中运行:

for (int i = 0; i < 1000; i++)
    Console.Write(".");

await longTask;

当这段代码运行时,线程池被通知运行预定的续体。它在线程池线程中并行运行。
for (int i = 0; i < 1000; i++)
    Console.Write("<");

"

控制台是线程安全的,这是一件好事,因为它会被两个线程同时调用。如果不是这样,你的程序可能会崩溃!

"

1

由于它们同时发生,所以它们在不同的线程上。这完全取决于处理任务的上下文。您可以设置它只允许一次运行一个任务,或者多个。默认情况下,它处理多个任务。

在这种情况下,当您调用DoSomething()时,它开始运行。在打印线程ID时,它显示在延迟后另一个线程启动以打印<。通过在方法中使用await,它创建另一个线程来运行它,并回退到运行Main,因为没有await告诉它在继续之前等待它。

至于代码是否将继续运行在相同的线程上,这取决于给定的上下文和是否使用ConfigureAwait。例如,ASP.NET没有上下文,因此执行将继续在任何可用的线程中进行。在WPF中,默认情况下,它将在开始时在同一线程上运行,除非使用ConfigureAwait(false)告诉系统它可以在任何可用的线程上运行。

在这个控制台示例中,在等待DoSomething()之后,Main将继续在其线程上运行,而不是在启动整个程序的线程上运行。这是合理的,因为控制台程序没有上下文来管理哪个线程运行哪个部分。
以下是修改后的代码,用于测试特定实现中发生的情况:
static async Task Main(string[] args)
{
    Console.WriteLine("Start: " + Thread.CurrentThread.ManagedThreadId);
    var longTask = DoSomethingLong();
    Console.WriteLine("After long: " + Thread.CurrentThread.ManagedThreadId);

    for (int i = 0; i < 1000; i++)
        Console.Write(".");

    Console.WriteLine("Before main wait: " + Thread.CurrentThread.ManagedThreadId);
    await longTask;
    Console.WriteLine("After main wait: " + Thread.CurrentThread.ManagedThreadId);
}

static async Task DoSomethingLong()
{
    Console.WriteLine("Before long wait: " + Thread.CurrentThread.ManagedThreadId);
    await Task.Delay(10);
    Console.WriteLine("After long wait: " + Thread.CurrentThread.ManagedThreadId);
    for (int i = 0; i < 1000; i++)
        Console.Write("<");
}

在我的 (.NET Core 3.0 preview 7) 上会显示如下内容:
开始: 1 长时间后: 1 长等待之前: 1 长等待之后: 4 主等待之前: 1 主等待之后: 4
最后一个数字可能是1或4,这取决于哪个线程恰好可用。这展示了如何等待Task.Delay()将退出DoSomethingLong()方法,继续运行Main,在延迟之后,它需要另一个线程来继续运行DoSomethingLong(),因为原始线程正在忙于其他事情。
有趣的是(至少对我而言),即使直接使用await运行DoSomethingLong(),ManagedThreadId也会做同样的事情。我本以为在这种情况下只会有一个线程ID 1,但似乎实现并非如此。

2
请停止说async与在不同线程上运行有关,这完全不正确。 - DavidG
@DavidG 我是在说这个程序在这种情况下的作用。我从来没有说过async就是它的全部。它只是表现为这样。或者也许你可以解释一下当两个任务同时在不同的线程上运行时要使用什么确切措辞,如果不是线程的话? - Sami Kuhmonen
请阅读我上面添加的链接,那里没有线程,即使作为比喻也是不正确的。 - DavidG
1
@DavidG,所以当.NET告诉我有不同的线程在运行时,实际上没有线程?你声称从来没有任何线程吗?我确实理解通常情况下不存在线程的情况,但在这种情况下,两个线程同时运行两个任务。这就是.NET自己告诉我们的。那么请解释一下为什么没有线程。你给出的链接适用于“等待事物的单一代码路径”,而不是“同时运行两个路径”的情况。 - Sami Kuhmonen
所以在这种特殊情况下,使用了2个线程并且实现了一些并行性。但是,例如,如果我在启动DoSomethingLong的代码行中等待它完成,我仍然可以使用2个线程(一个在await之前,另一个在await之后),但是没有并行性,对吗?当线程1遇到await时,它会怎么做?它将返回线程池,因为没有工作需要它执行? - agnieszka
@agnieszka 是的,在我使用的实现(Win10 x64上的Core 3.0-Preview7)中,即使直接等待,也会创建两个线程而没有并行性。在这种情况下,第一个线程将进入池等待要做的事情。 - Sami Kuhmonen

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接