同步上下文是做什么的?

178

在《Programming C#》一书中,有关于SynchronizationContext的一些示例代码:

SynchronizationContext originalContext = SynchronizationContext.Current;
ThreadPool.QueueUserWorkItem(delegate {
    string text = File.ReadAllText(@"c:\temp\log.txt");
    originalContext.Post(delegate {
        myTextBox.Text = text;
    }, null);
});

我对线程比较生疏,请详细回答。首先,我不知道上下文是什么意思,程序在originalContext中保存了什么?当触发Post方法时,UI线程会做什么?
如果我问一些傻问题,请指出,谢谢!

编辑:例如,如果我在方法中只写myTextBox.Text = text;,有何区别?


1
精细手册中提到:此类实现的同步模型的目的是允许公共语言运行时的内部异步/同步操作在不同的同步模型下正常工作。该模型还简化了托管应用程序必须遵循的某些要求,以便在不同的同步环境下正确工作。 - ta.speot.is
在我看来,async await 已经做到了这一点。 - Royi Namir
9
是的,但你猜怎么着:async/await 在底层上依赖于 SynchronizationContext - stakx - no longer contributing
2
这篇由Stephen Toub撰写的文章非常棒,Await, SynchronizationContext, and Console Apps - Timeless
8个回答

231

SynchronizationContext是什么?

简单来说,SynchronizationContext代表代码可能被执行的位置。传递给其SendPost方法的委托将在该位置调用。(PostSend的非阻塞/异步版本。)

每个线程都可以与一个SynchronizationContext实例相关联。通过调用静态的SynchronizationContext.SetSynchronizationContext方法,可以将运行线程与同步上下文相关联,并且可以通过SynchronizationContext.Current属性查询正在运行的线程的当前上下文。

尽管我刚才写的是每个线程都有一个相关联的同步上下文,但是一个 SynchronizationContext 并不一定代表一个具体的线程;它也可以将传递给它的委托转发到数个线程中(例如到 ThreadPool 工作线程),或者(至少在理论上)转发到特定的CPU核心,甚至可以转发到另一台网络主机。你的委托最终在哪个线程上运行取决于所使用的 SynchronizationContext 的类型。

Windows Forms 会在创建第一个窗体的线程上安装一个 WindowsFormsSynchronizationContext。(这个线程通常称为“UI线程”。) 这种类型的同步上下文会在完全那个线程上调用传递给它的委托。这非常有用,因为 Windows Forms,像许多其他 UI 框架一样,只允许在创建它们的同一线程上操作控件。

如果我在方法中只是写 myTextBox.Text = text;,有什么区别吗?

The code that you've passed to ThreadPool.QueueUserWorkItem will be run on a thread pool worker thread. That is, it will not execute on the thread on which your myTextBox was created, so Windows Forms will sooner or later (especially in Release builds) throw an exception, telling you that you may not access myTextBox from across another thread.
This is why you have to somehow "switch back" from the worker thread to the "UI thread" (where myTextBox was created) before that particular assignment. This is done as follows:
  1. 当您仍在UI线程上时,捕获Windows Forms的SynchronizationContext,并将其存储在一个变量(originalContext)中以供以后使用。此时,您必须查询SynchronizationContext.Current; 如果您在传递给ThreadPool.QueueUserWorkItem的代码中查询它,您可能会得到与线程池工作线程相关联的任何同步上下文。一旦您存储了对Windows Forms上下文的引用,就可以在任何地方和任何时间使用它来“发送”代码到UI线程。

  2. 每当您需要操作UI元素(但可能不在UI线程上)时,请通过originalContext访问Windows Forms的同步上下文,并将将操作UI的代码移交给SendPost


最后的备注和提示:

  • 同步上下文不能告诉你哪些代码必须在特定位置/上下文中运行,哪些代码可以正常执行而不需要将其传递给“同步上下文”。为了决定这一点,您必须了解您正在编程的框架的规则和要求-在这种情况下是Windows Forms。

    因此,请记住Windows Forms的简单规则:不要从创建它们的线程以外的线程访问控件或窗体。如果必须这样做,请使用上述描述的“同步上下文”机制,或者Control.BeginInvoke(这是一种专门针对Windows Forms进行相同操作的方式)。

  • 如果您正在使用.NET 4.5或更高版本进行编程,则可以通过将明确使用SynchronizationContextThreadPool.QueueUserWorkItemcontrol.BeginInvoke等的代码转换为新的async/await关键字Task Parallel Library (TPL),即围绕TaskTask<TResult>类的API,使您的生活变得更加轻松。这些将在很大程度上负责捕获UI线程的同步上下文、启动异步操作,然后回到UI线程,以便您可以处理操作的结果。


你说_Windows Forms_,像许多其他UI框架一样,只允许在同一线程上操作控件,但Windows中的所有窗口都必须由创建它的同一线程访问。 - Sam Hobbs
5
不,那不正确。你可以有几个线程创建Windows Forms控件。但是每个控件都与创建它的线程相关联,并且只能由该线程访问。来自不同UI线程的控件在彼此之间的交互方面也非常有限:它们不能成为父/子关系,它们之间无法进行数据绑定等。最后,创建控件的每个线程都需要自己的消息循环(我记得通过 Application.Run 开始)。这是一个相当高级的主题,不是随意做的事情。 - stakx - no longer contributing
4
所有这些关于“窗户”和“Windows窗户”的讨论使我感到有些头晕。我是否提到过任何这些“窗户”?我想没有…… - stakx - no longer contributing
可能会获取与线程池工作线程关联的任何同步上下文,那么工作线程同步上下文是什么? - ibubi
1
@ibubi:我不确定我理解你的问题。任何线程的同步上下文都是未设置(null)或SynchronizationContext的实例(或其子类)。那句话的重点不在于你得到了什么,而在于你不会得到UI线程的同步上下文。 - stakx - no longer contributing
显示剩余3条评论

31
我想补充其他答案,SynchronizationContext.Post只是将回调排队以便稍后在目标线程上执行(通常是在目标线程的消息循环的下一个周期中),然后继续在调用线程上执行。另一方面,SynchronizationContext.Send试图立即在目标线程上执行回调,这会阻塞调用线程并可能导致死锁。在两种情况下,都存在代码重入的可能性(在同一执行线程上进入类方法,在对同一方法的先前调用返回之前)。
如果您熟悉Win32编程模型,则非常接近的类比是PostMessage 和SendMessage API,您可以调用它们以从与目标窗口不同的线程分派消息。
这里有一个非常好的解释,介绍了同步上下文: 重点是SynchronizationContext。

20

它存储了同步提供程序,这是一个派生自SynchronizationContext的类。在这种情况下,它可能是WindowsFormsSynchronizationContext的实例。该类使用Control.Invoke()和Control.BeginInvoke()方法来实现Send()和Post()方法。或者它可以是DispatcherSynchronizationContext,它使用Dispatcher.Invoke()和BeginInvoke()。在Winforms或WPF应用程序中,只要创建窗口,该提供程序就会自动安装。

当您在另一个线程上运行代码(例如本片段中使用的线程池线程)时,必须小心,不要直接使用不安全的线程对象。像任何用户界面对象一样,您必须从创建TextBox的线程更新TextBox.Text属性。Post()方法确保代理目标在该线程上运行。

请注意,此代码片段有点危险,仅在您从UI线程调用它时才能正常工作。SynchronizationContext.Current在不同的线程中具有不同的值。只有UI线程有可用值。这就是代码必须复制它的原因。在Winforms应用程序中,有一种更可读且更安全的方法:

    ThreadPool.QueueUserWorkItem(delegate {
        string text = File.ReadAllText(@"c:\temp\log.txt");
        myTextBox.BeginInvoke(new Action(() => {
            myTextBox.Text = text;
        }));
    });

使用SynchronizationContext.Current的优点是,它可以在任何线程中调用。在Winforms或WPF中使用代码时,使用SynchronizationContext.Current的优点在于它在库中也同样适用。但这绝对不是一个好的示例,因为在此处您总是知道所使用的TextBox的类型,因此您始终知道是使用Control.BeginInvoke还是Dispatcher.BeginInvoke。实际上,使用SynchronizationContext.Current并不常见。

该书试图教授有关线程的知识,因此使用这种有缺陷的示例还算可以。在实际情况下,在少数情况下,您可能会考虑使用SynchronizationContext.Current,但仍然可以让C#的异步/等待关键字或TaskScheduler.FromCurrentSynchronizationContext()来为您完成。但请注意,当您在错误的线程上使用它们时,它们仍然会像代码片段一样表现不良,原因完全相同。这是一个非常常见的问题,额外的抽象层次很有用,但使得更难弄清楚为什么它们不能正常工作。希望该书也告诉您何时不使用它 :)


对不起,为什么让UI线程处理是线程安全的?例如,当Post()触发时,我认为UI线程可能正在使用myTextBox,这样安全吗? - cloudyFan
5
你的英文很难理解。你原来的片段只有在从UI线程调用时才能正常工作,这是一种非常常见的情况。只有这样才会将其发送回UI线程。如果从工作线程调用它,则Post()代理目标将在线程池线程上运行。炸了。这是你想亲自尝试的事情。启动一个线程并让线程调用此代码。如果代码崩溃并出现NullReferenceException异常,则说明你做对了。 - Hans Passant
“你必须从创建 TextBox 的线程更新 TextBox.Text 属性” - 为什么他们要这样设计呢? - David Klempfner
@DavidKlempfner 可能是为了避免过多和复杂的锁定。请参考这个问题 - Alex Che

7

同步上下文的目的是确保myTextbox.Text = text;在主 UI 线程上调用。

Windows 要求仅通过创建它们的线程访问 GUI 控件。如果您尝试在后台线程中分配文本而不先进行同步(通过多种方式,例如此处或调用模式),则会抛出异常。

这样做的作用是在创建后台线程之前保存同步上下文,然后后台线程使用 context.Post 方法执行 GUI 代码。

是的,你展示的代码基本上是无用的。为什么创建一个后台线程,只需要立即返回到主 UI 线程?它只是一个例子。


8
"是的,您展示的代码基本上没有用。为什么要创建一个后台线程,然后立即需要回到主UI线程?这只是一个例子。" - 从文件中读取可能是一个耗时的任务,特别是当文件很大时,这可能会阻塞UI线程并使其无法响应。 - Yair Nevet
我有一个愚蠢的问题。每个线程都有一个ID,我想UI线程也有一个ID=2,例如。那么,当我在线程池线程上时,我可以像这样做些什么:var thread = GetThread(2); thread.Execute(() => textbox1.Text = "foo")吗? - John
@John - 不,我认为那样行不通,因为线程已经在执行了。你不能执行一个已经在执行的线程。只有当线程没有运行时才能执行(如果我没记错的话)。 - Erik Funkenbusch

7

同步上下文(SynchronizationContext)基本上是回调委托执行的提供者。它负责确保在程序中特定代码块(在 .Net TPL 中封装在 Task 对象内部)执行完成后,委托将在给定的执行上下文中运行。

从技术角度来看,SC 是一个简单的 C# 类,其重点是支持和提供其功能,专门针对于任务并行库对象。

除了控制台应用程序外,每个 .Net 应用程序都有一个定制的该类实现,基于特定的底层框架,例如:WPF、WindowsForm、Asp Net、Silverlight 等。

这个对象的重要性在于绑定了代码异步执行返回结果和等待这些异步工作结果的依赖代码之间的同步。

而“上下文”一词代表了执行上下文,也就是等待代码将被执行的当前执行上下文——即异步代码与等待代码之间的同步发生在特定的执行上下文中。因此,这个对象被命名为同步上下文。

它代表着将负责异步代码和等待代码执行的同步过程的执行上下文


5

源头文章

每个线程都有一个与之关联的上下文,也称为“当前”上下文,这些上下文可以在线程之间共享。ExecutionContext包含了程序所处执行环境或上下文的相关元数据。SynchronizationContext表示一种抽象——它指代应用程序代码执行的位置。

SynchronizationContext使您能够将任务排队到另一个上下文中。请注意,每个线程都可以有自己的SynchronizationContext。

例如:假设您有两个线程Thread1和Thread2。 假设Thread1正在执行一些工作,然后Thread1希望在Thread2上执行代码。 一种可能的方法是要求Thread2提供其SynchronizationContext对象,将其提供给Thread1,然后Thread1可以调用SynchronizationContext.Send在Thread2上执行该代码。


4
同步上下文并不一定与特定线程绑定。多个线程可以处理对单个同步上下文的请求,一个线程也可以处理对多个同步上下文的请求。 - Servy
@Servy 他写道:“请注意,每个线程都可以拥有自己的同步上下文。” 这是真的,不是吗?“可以”意味着“可能”。 - David Klempfner
2
@DavidKlempfner 问题不在答案中的引用,而在作者对其的评论。你引用的那一部分并不是问题所在。问题在于作者的陈述,即如果你从线程中获取同步上下文,则意味着它将在该线程上执行已发布的委托。答案引用的部分只是说线程可能具有上下文,而没有评论实际发布时发生了什么。 - Servy

3

SynchronizationContext提供了一种方法,可以通过不同的线程更新UI(通过Send方法同步或通过Post方法异步)。

请看以下示例:

    private void SynchronizationContext SyncContext = SynchronizationContext.Current;
    private void Button_Click(object sender, RoutedEventArgs e)
    {
        Thread thread = new Thread(Work1);
        thread.Start(SyncContext);
    }

    private void Work1(object state)
    {
        SynchronizationContext syncContext = state as SynchronizationContext;
        syncContext.Post(UpdateTextBox, syncContext);
    }

    private void UpdateTextBox(object state)
    {
        Thread.Sleep(1000);
        string text = File.ReadAllText(@"c:\temp\log.txt");
        myTextBox.Text = text;
    }

SynchronizationContext.Current会返回UI线程的同步上下文。我是如何知道这个的呢?在每个窗体或WPF应用程序开始时,上下文将在UI线程上设置。如果您创建一个WPF应用程序并运行我的示例,您将看到当您单击按钮时,它会休眠大约1秒钟,然后会显示文件的内容。您可能会认为它不会这样,因为UpdateTextBox方法的调用者(即Work1)是传递给Thread的方法,因此它应该使该线程休眠而不是主UI线程,但实际上并非如此!即使Work1方法被传递给线程,注意它还接受一个对象,即SyncContext。如果您查看它,您将看到UpdateTextBox方法通过syncContext.Post方法执行而不是Work1方法。请看以下内容:

private void Button_Click(object sender, RoutedEventArgs e) 
{
    Thread.Sleep(1000);
    string text = File.ReadAllText(@"c:\temp\log.txt");
    myTextBox.Text = text;
}

最后一个例子和这个例子执行的是相同的功能。两者在执行任务时都不会阻塞用户界面。
结论上来说,可以将SynchronizationContext视为线程。它不是一个线程,而是定义了一个线程(请注意,并非所有线程都有SyncContext)。每当我们调用Post或Send方法以更新UI时,就像从主UI线程正常更新UI一样。如果出于某些原因,您需要从不同的线程更新UI,请确保该线程具有主UI线程的SyncContext,并使用要执行的方法调用其上的Send或Post方法,然后您就完成了全部设置。
希望这能帮到你!

1
这个例子来自Joseph Albahari的Linqpad示例,它真正帮助理解同步上下文的作用。
void WaitForTwoSecondsAsync (Action continuation)
{
    continuation.Dump();
    var syncContext = AsyncOperationManager.SynchronizationContext;
    new Timer (_ => syncContext.Post (o => continuation(), _)).Change (2000, -1);
}

void Main()
{
    Util.CreateSynchronizationContext();
    ("Waiting on thread " + Thread.CurrentThread.ManagedThreadId).Dump();
    for (int i = 0; i < 10; i++)
        WaitForTwoSecondsAsync (() => ("Done on thread " + Thread.CurrentThread.ManagedThreadId).Dump());
}

1
这是怎么回事?你能解释一下这段代码吗? - Hugh W
取决于看它的人 :) - loneshark99

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