使用C#了解.NET中的SynchronizationContext

SynchronizationContext Demo

介绍 (Introduction)

Here, I intend to shed some light on another dark corner of the .NET framework - synchronization contexts. I will take you through understanding why they exist, what they do, and how they work. In the end, we are even going to implement our own. This article assumes at least a passing familiarity with multithreading. It doesn't require that you have written much if any multithreaded code before, as long as you understand the core principles and caveats of it. I'll be covering a little bit of it anyway.

在这里,我打算阐明.NET框架的另一个黑暗角落-同步上下文。 我将带您了解它们为什么存在,它们做什么以及它们如何工作。 最后,我们甚至要实现自己的。 本文假定至少对多线程有一定的了解。 只要您了解多线程代码的核心原理和注意事项,就不需要编写过多的代码。 无论如何,我都会讲一点。

Note: This article's source code includes the rest of my Tasks framework as it exists so far. The relevant projects are SyncContextDemo, and Tasks. Under Tasks, you'll find MessagingSynchronizationContext.cs, which uses MessageQueue.cs which is auxiliary.

注意 :本文的源代码包括到目前为止我的Tasks框架的其余部分。 相关项目是SyncContextDemoTasks 。 在“ Tasks ,您将找到MessagingSynchronizationContext.cs ,该消息使用辅助的MessageQueue.cs

Update: It may not have impacted your existing code, but there is a potential problem with the first revision wherein the SemaphoreSlim and the ConcurrentQueue<T> get out of sync depending on how you use it. This is not desirable. I've updated the code and the examples to reflect the changes.

更新 :可能不会影响您现有的代码,但是第一个修订版存在潜在的问题,根据您的使用方式, SemaphoreSlimConcurrentQueue<T>不同步。 这是不希望的。 我已经更新了代码和示例以反映所做的更改。

概念化这个混乱 (Conceptualizing this Mess)

什么是同步上下文? (What is a Synchronization Context?)

First let's talk about the problem it solves. With multithreaded code, you can't simply read and write values across threads with impunity, which also implies you can't just call methods and properties across threads either because you might cause a race condition, which is one of the worst things to have to debug in programming. Writing multithreaded code is complicated, easy to get wrong, and hard to debug. There's got to be an easier way!

首先,让我们谈谈它解决的问题。 使用多线程代码,您不能简单地在不受惩罚的情况下跨线程读取和写入值,这还意味着您也不能只是跨线程调用方法和属性,因为这可能会导致竞争状态,这是最糟糕的事情之一在编程中进行调试。 编写多线程代码很复杂,容易出错,并且难以调试。 一定有一个更简单的方法!

I'd like to entertain a funny idea: consider that there are "boundaries" between threads - invisible walls you have to get through. Between those walls is where your (member) data lives. Don't cross those boundaries without preparation. If you've written multithreaded code, this should be easy to grasp if not utterly familiar.

我想提出一个有趣的想法:考虑线程之间存在“边界”,即必须穿过的不可见墙。 在这些墙之间是您的(成员)数据所在的位置。 未经准备就不要越过这些界限。 如果您已经编写了多线程代码,那么即使不是很熟悉它也应该很容易理解。

How do we cross those boundaries? It depends. There are many ways to do so, a couple of primary ones being somewhat crude synchronization primitives (like mutexes and semaphores), and much more advanced message passing (which actually builds on synchronization primitives.)

我们如何跨越这些界限? 这取决于。 这样做的方法有很多,其中几个主要的方法是有点粗糙的同步原语(例如互斥体和信号量),以及更高级的消息传递(实际上是基于同步原语构建的)。

The question is, can we abstract something that is flexible that allows us to communicate across thread boundaries regardless of the underlying implementation, and present a facade to the developer that makes it easy to use?

问题是,我们能否抽象出一种灵活的东西,使我们能够跨越线程边界进行通信,而不管其底层实现方式如何,并向开发人员展示一个易于使用的外观?

The answer is essentially yes, and that's what Microsoft did with the SynchronizationContext. This is basically a contract class, because you derive from it in order to make it do anything. The default implementation just hops over the wall without doing any synchronization.

答案本质上是肯定的,这就是Microsoft对SynchronizationContext所做的。 这基本上是一个合同类,因为您可以从它派生出它以便执行任何操作。 默认实现只是跳过墙而没有进行任何同步。

However, you're sometimes not dealing with the default synchronization context. WinForms has its own that it uses to help you for example, safely run BackgroundWorker tasks and report back to the UI even though the reporting starts off in separate thread than the UI thread. Remember you can't just cross a thread boundary like that.

但是,有时您没有处理默认的同步上下文。 WinForms有其自己的用途,例如,尽管报告是从与UI线程不同的线程开始的,但WinForms可以帮助您安全地运行BackgroundWorker任务并向UI报告。 请记住,您不能像这样跨线程边界。

The SynchronizationContext and its derivatives work like a message queue, or at least that's the facade they present to the developer. With it, you can execute delegates in one of two ways on the target thread - the one where our message loop "lives". We'll get to the message loop in a bit. The first way to dispatch a delegate to a target thread is Post() and it's asynchronous, but it doesn't let you know when it finished. The second way is Send() which is synchronous and blocks the sender until the recipient completes execution of the delegate. That's not great, but it's what we have. Due to the nature of message queues, bidirectional communication isn't possible - they're one way so you'd need two. That's why Post() doesn't notify you.

SynchronizationContext及其派生类就像消息队列一样工作,或者至少是它们呈现给开发人员的外观。 有了它,您可以在目标线程上以两种方式之一执行委托-一种消息循环“存在”的方式。 我们将稍后讨论消息循环。 将委托分配给目标线程的第一种方法是Post() ,它是异步的,但是在完成时不会通知您。 第二种方法是Send() ,它是同步的,并阻止发送方,直到接收方完成委托的执行。 那不是很好,但这就是我们所拥有的。 由于消息队列的性质,无法进行双向通信-它们是一种方式,因此您需要两种方式。 这就是为什么Post()不会通知您的原因。

The other thing about a synchronization context is each thread can be associated with one. This is sometimes but not always the same thread with the message loop that looks for incoming messages. Delegates can be dispatched to the thread running a message loop so that the thread may execute them. We'll cover what it looks like further down.

关于同步上下文的另一件事是每个线程可以与一个线程相关联。 有时但并不总是与用于查找传入消息的消息循环相同的线程。 可以将委派给运行消息循环的线程,以便线程可以执行它们。 我们将进一步介绍它的外观。

为什么要抽象呢? (Why Abstract it at All?)

This is a good question. The answer is that you can extend it and some of the framework can consume it. The await mechanism inserts calls to it into the code for your async routine in order to make sure the code before await and the code after await execute in the same context (on the same thread). Other times, the framework will provide its own, like the one WinForms implements, which keeps the UI thread safe when for example, a BackgroundWorker (which consumes it) communicates with it.

这是一个很好的问题。 答案是您可以扩展它,并且某些框架可以使用它。 await机制将对它的调用插入到async例程的代码中,以确保await之前的代码和await之后的代码在同一上下文中(在同一线程上)执行。 在其他时候,该框架将提供自己的框架,例如WinForms的一种实现,例如当BackgroundWorker (使用它)与其通信时,该框架可使UI线程保持安全。

编码此混乱 (Coding this Mess)

我们如何使用它? (How Do We Use It?)

You can get the current synchronization context for a thread by retrieving SynchronizationContext.Current. You can set it by calling SetSynchronizationContext().

您可以通过检索SynchronizationContext.Current获取线程的当前同步上下文。 您可以通过调用SetSynchronizationContext()

Once you have it you can call Post() to fire and forget a delegate on the SynchronizationContext's associated message loop thread, or you can call Send() to block until the foreign execution is complete.

拥有它后,您可以调用Post()来触发并忘记SynchronizationContext的关联消息循环线程上的委托,或者可以调用Send()进行阻止,直到完成外部执行为止。

If you create a new thread, you can set its synchronization context to the one that's driven by your UI thread - the thread with your Forms on it, where you called Application.Run() because that's where the WinForms synchronization context runs. So you do like:

如果创建一个新线程,则可以将其同步上下文设置为由UI线程驱动的线程-带有Form的线程,在该线程上调用Application.Run()因为这是WinForms同步上下文运行的地方。 所以你喜欢:

// in a Winforms app UI thread somewhere:
var sctx = SynchronizationContext.Current;
var thread = new Thread(() => {
    // now await and other things can dispatch messages to 
    // to sctx which here in WinForms will be the UI's 
    // SynchronizationContext:
    SynchronizationContext.SetSynchronizationContext(sctx); 
    // ... do work including sctx.Post() and/or sctx.Send()
});
thread.Start();

You don't really need to set the thread's synchronization context in the case where we used it above, because we have access to sctx directly so we didn't have to query SynchronizationContext.Current but other things, like await rely on it, so you really should set it.

在上面我们使用它的情况下,您实际上并不需要设置线程的同步上下文,因为我们可以直接访问sctx ,因此我们不必查询SynchronizationContext.Current但是还有其他东西,例如await依赖于它,因此你真的应该设置它。

Once you have one, either by retrieving the Current property, or by hoisting like we did above, we can call Post() and Send().

一旦拥有一个(通过检索Current属性或像上面一样吊起),我们可以调用Post()Send()

Your message, which is typically transmitted from another thread, is then dispatched on the receiving context's associated message loop thread. The transmitting of messages looks like this:

您的消息(通常是从另一个线程发送的)然后分派到接收上下文的关联消息循环线程上。 消息的传输如下所示:

// executes on the target thread, not this thread:
sctx.Post((object state) => { MessageBox.Show(string.Format("Hello from thread {0} (via Post)",
 Thread.CurrentThread.ManagedThreadId)); }, null);

The anonymous method executes on the target thread, in this case, the UI thread, where calls to MessageBox.Show() are safe.

匿名方法在目标线程(在本例中为UI线程)上执行,在该线程上对MessageBox.Show()调用是安全的。

Send() works exactly the same way except it blocks until the target delegate has completed executing.

Send()工作方式完全相同,只是它会阻塞直到目标委托完成执行为止。

We can use these to basically shift code to other threads as long as those threads have message loop and a synchronization context.

只要这些线程具有消息循环和同步上下文,我们就可以使用它们将代码基本上转移到其他线程。

它是如何工作的? (How Does It Work?)

It's not magic, it's just messaging, I promise. First to understand it, let's take a look at a message loop for a particular implementation of a SynchronizationContext I built:

我保证,这不是魔术,只是消息。 首先了解它,让我们看一下我构建的SynchronizationContext的特定实现的消息循环:

Message msg;
do
{
    // blocks until a message comes in:
    msg = _messageQueue.Receive();
    // execute the code on this thread
    msg.Callback?.Invoke(msg.State);
    // let Send() know we're done:
    if (null != msg.FinishedEvent)
        msg.FinishedEvent.Set();
    // exit on the quit message
} while (null != msg.Callback);

I don't especially like the name Callback but I used it because that's what Microsoft calls it in their default implementation and the delegate Send() and Post() take. What it is is the delegate (usually to an anonymous method) containing the code to execute, like we did with Post() and Send() before. If it's null, that signifies to stop the message loop but this detail is specific to my implementation. Finally, we simply call the delegate we received in the message, since now we're on the target thread.

我不特别喜欢Callback这个名称,但我使用它是因为Microsoft在其默认实现中称呼它,而委托Send()Post()采用它。 就像我们之前对Post()Send()所做的那样,它是包含要执行的代码的委托(通常是匿名方法)。 如果为null ,则表示停止消息循环,但此详细信息特定于我的实现。 最后,我们现在简单地调用消息中收到的委托,因为现在我们位于目标线程上。

Now let's look at the demo, which is illustrative, if contrived:

现在让我们看一下该演示,它是说明性的,如果有人为的话:

// determine if we are using a synchronization context other than the default
// if it's null, we're using the default, which executes code on the same thread
// that Send() or Post() sent it on.
Console.WriteLine("Current thread id is {0}", Thread.CurrentThread.ManagedThreadId);
Console.WriteLine("Synchronization context is {0}set",
                   SynchronizationContext.Current == null?"not ":"");
            
// create a new custom synchronization context
var sc = new MessagingSynchronizationContext();
Console.WriteLine("Setting context to MessageQueueSynchronizationContext");
                    

// now start our message loop, and an auxiliary thread
Console.WriteLine("Starting message loop for thread {0}",
                   Thread.CurrentThread.ManagedThreadId);
var thread = new Thread(() => {
    // always set the synchronization context if you'll be using 
    // a non-default one on the thread
    SynchronizationContext.SetSynchronizationContext(sc);
                
    // don't use the synchronization context - posts from this thread:
    Console.WriteLine("Hello from thread {0}", Thread.CurrentThread.ManagedThreadId);
                
    // use the synchronization context - posts from Main()'s thread:
    sc.Post((object state) => { Console.WriteLine("Hello from thread {0} (via Post)", 
                                Thread.CurrentThread.ManagedThreadId); }, null);
});
// start the auxiliary thread
thread.Start();
var task = Task.Run(async () =>
{
    // set the synchronization context
    // uncomment this to see what happens!
    // SynchronizationContext.SetSynchronizationContext(sc);
    Thread.Sleep(50);
    Console.WriteLine("Awaiting task");
    await Task.Delay(50);
    // this will wake up on main thread or not
    // depending on the synchronization context
    Console.WriteLine("Hello from thread {0} (via await)", 
                       Thread.CurrentThread.ManagedThreadId);
});

// start the message loop:
sc.Start(); // blocks
// doesn't matter but shuts up the compiler:
await task;

It should output something like this:

它应该输出如下内容:

Current thread id is 1
Synchronization context is not set
Setting context to MessageQueueSynchronizationContext
Starting message loop for thread 1
Hello from thread 3
Hello from thread 1 (via Post)
Awaiting task
Hello from thread 4 (via await)

Now try uncommenting this line (where it says "see what happens!"):

现在尝试取消注释此行(它说“ see what happens! ”):

SynchronizationContext.SetSynchronizationContext(sc);

Now run the program. This time it said thread 1 (or whatever your current thread id said). You'll note that the await this time didn't cause the code to execute on a new thread. What's this sorcery? awaits use the current synchronization context to execute code that comes after the await:

现在运行程序。 这次它说的是线程1(或您当前的线程ID所说的)。 您会注意到,这次等待并未导致代码在新线程上执行。 这是什么法术? await使用当前的同步上下文执行await之后的代码:

var sctx = SynchronizationContext.Current??new SynchronizationContext();

sctx.Post(()=>{ /* next portion of code */ },null);

I'll explain more later.

稍后再解释。

Note that SynchronizationContext does not have a Start() method on it. How the message loop a synchronization context uses is implemented is an opaque detail we're not supposed to consider. However, in our custom implementation, we need something to serve as our message loop, and I essentially just provided a boilerplate one behind that method to keep things simple.

请注意, SynchronizationContext上没有Start()方法。 同步上下文使用的消息循环的实现方式是一个不透明的细节,我们不应该考虑。 但是,在我们的自定义实现中,我们需要一些东西来充当消息循环,而我实质上只是在该方法后面提供了一个样板,以使事情变得简单。

If you don't quite understand how it works yet, let me go over it again. Somewhere, there's a message loop. Where it is in your code or the bowels of the framework is an implementation detail. The point is that whatever thread it runs, the message loop on is where the code will finally be executed. You call Send() and Post() from other threads with delegates "containing" your code to be "transported" and executed on that target thread. This allows for easy cross thread communication.

如果您还不太了解它的工作原理,请让我再讲一遍。 某个地方有一个消息循环。 它在您的代码或框架中的位置是一个实现细节。 关键是,无论它运行的是哪个线程,消息循环都将在该循环中最终执行代码。 您可以从其他线程调用Send()Post() ,并委托将“包含”您的代码“传输”并在目标线程上执行。 这样可以轻松进行跨线程通信。

你如何使自己的? (How Do You Make Your Own?)

Sometimes, like when you're in a console app or Windows service, you will not have a good SynchronizationContext to use. The problem is there's no message loop. If you want one, you have to make one, and that's what this is for. It should be sufficient for custom threading scenarios where you need code executed on a thread of your choosing. We'll explore it here. First, we have a nested struct declaration and an important member field:

有时,例如当您使用控制台应用程序或Windows服务时,将没有很好的SynchronizationContext使用。 问题是没有消息循环。 如果您想要一个,就必须制造一个,这就是这个目的。 对于需要在您选择的线程上执行代码的自定义线程方案而言,这应该足够了。 我们将在这里进行探索。 首先,我们有一个嵌套的struct声明和一个重要的成员字段:

private struct Message
{
    public readonly SendOrPostCallback Callback;
    public readonly object State;
    public readonly ManualResetEventSlim FinishedEvent;
    public Message(SendOrPostCallback callback, 
                   object state, ManualResetEventSlim finishedEvent)
    {
        Callback = callback;
        State = state;
        FinishedEvent = finishedEvent;
    }
    public Message(SendOrPostCallback callback, object state) : this(callback, state, null)
    {
    }
}
MessageQueue<Message> _messageQueue = new MessageQueue<Message>();

The message contains the "callback" I mentioned earlier, a field for optional user state which you passed to Send() or Post() and finally a ManualResetEventSlim which I'll explain as we get further along. It is used for signalling to Send() that we've processed the message so that Send() is able to block until it's received. This type declares all the information we need to execute a delegate on the message loop thread.

该消息包含我前面提到的“ callback ”,一个传递给Send()Post()可选用户状态字段,最后是一个ManualResetEventSlim ,我们将在后面进行介绍。 它用于向Send()发出信号,表明我们已经处理了该消息,以便Send()能够阻塞直到被接收。 此类型声明我们在消息循环线程上执行委托所需的所有信息。

Next, we have something called a MessageQueue that holds Message struct instances as declared above. This class provides a thread safe way to communicate by posting and receiving Messages. It does most of the heavy lifting, but we'll explore that as well eventually.

接下来,我们有一个称为MessageQueue东西,其中包含如上所述的Message struct实例。 这个class提供了一个线程安全的方式通过发布和接收通信的Message秒。 它完成了大部分繁重的工作,但最终我们也会对此进行探讨。

The above is specific to our implementation of a SynchronizationContext. You may very well have your own way of communicating across threads, and you can implement whatever you like as long as it fulfills the necessary contract provided by SynchronizationContext.

上面是特定于我们对SynchronizationContext的实现。 您可能有自己的跨线程通信方式,并且只要满足SynchronizationContext提供的必要合同,就可以实现自己喜欢的任何方式。

Here are the Send() and Post() implementations for our custom synchronization context:

这是我们自定义同步上下文的Send()Post()实现:

/// <summary>
/// Sends a message and does not wait
/// </summary>
/// <param name="callback">The delegate to execute</param>
/// <param name="state">The state associated with the message</param>
public override void Post(SendOrPostCallback callback, object state)
{
    _messageQueue.Post(new Message(callback, state));
}
/// <summary>
/// Sends a message and waits for completion
/// </summary>
/// <param name="callback">The delegate to execute</param>
/// <param name="state">The state associated with the message</param>
public override void Send(SendOrPostCallback callback, object state)
{
    var ev = new ManualResetEventSlim(false);
    try
    {
        _messageQueue.Post(new Message(callback, state, ev));
        ev.Wait();
    }
    finally
    {
        ev.Dispose();
    }
}

You can see Post() is straightforward. Send() is slightly more complicated because we must get notified when it finally completes, which is what our ManualResetEventSlim from earlier was before. Here we create it, post it with the Message, and then wait on it. In our message loop, it gets Set() signalling we can continue. Finally, we Dispose() of the event. It might be more efficient to recycle these events but doing so is significantly more complicated and I'm not sure how much performance would be gained, if any.

您可以看到Post()很简单。 Send()稍微复杂些,因为我们必须在它最终完成时得到通知,这是我们之前的ManualResetEventSlim所具有的。 在这里,我们创建它,将其与Message ,然后等待它。 在我们的消息循环中,它获取Set()信号以指示我们可以继续。 最后,我们对该事件进行Dispose() 。 回收这些事件可能会更有效,但是这样做要复杂得多,而且我不确定会获得多少性能。

Note we can pass a State with the Message. It gets sent to the Callback for processing, and its value is arbitrarily defined by the consumer.

注意,我们可以通过Message传递State 。 它被发送到Callback进行处理,其值由使用者任意定义。

Now let's look at our message loop in Start() again, hopefully it will be a little clearer this time:

现在让我们再次看一下Start()中的消息循环,希望这次会更清晰一些:

Message msg;
do
{
    // blocks until a message comes in:
    msg = _messageQueue.Receive();
    // execute the code on this thread
    msg.Callback?.Invoke(msg.State);
    // let Send() know we're done:
    if (null != msg.FinishedEvent)
        msg.FinishedEvent.Set();
    // exit on the quit message
} while (null != msg.Callback);

While Stop() looks like this:

虽然Stop()看起来像这样:

var ev = new ManualResetEventSlim(false);
try
{
    // post the quit message
    _messageQueue.Post(new Message(null, null, ev));
    ev.Wait();
}
finally {
    ev.Dispose();
}

Note how we're waiting for the message to complete. The reason this doesn't use Send(), but does the same thing is I've been considering adding a check for a null Callback and throwing in Send() if it finds one. This code ensures that the behavior here won't break if I add that check.

请注意我们如何等待消息完成。 它不使用Send()的原因,但做同一件事的原因是我一直在考虑添加对null Callback的检查,如果发现有一个,则抛出Send() 。 此代码确保了如果我添加该检查,此处的行为不会中断。

那么MessageQueue类呢? (What About the MessageQueue Class?)

The MessageQueue provides the core functionality to post and receive messages between threads. It uses ConcurrentQueue<T> and SemaphoreSlim to work its magic. The principle is that every time something adds a message (of type T) to the queue, they also call Release(1) on the semaphore, allowing the next Receive() to go through without blocking. The upshot of it is that this will only block if the queue is empty, so Receive() only blocks if there are no messages. Otherwise, it returns the next message in the queue, removing it:

MessageQueue提供了在线程之间发布和接收消息的核心功能。 它使用ConcurrentQueue<T>SemaphoreSlim发挥其魔力。 原理是,每当有东西向队列中添加一条消息(类型T )时,它们还会在信号量上调用Release(1) ,从而允许下一个Receive()通过而不会阻塞。 这样做的结果是,仅在队列为空时才阻塞,因此Receive()仅在没有消息时才阻塞。 否则,它将返回队列中的下一条消息,并将其删除:

T result;
_sync.Wait();
if (!_queue.TryDequeue(out result))
    throw new InvalidOperationException("The queue is empty");
return result;

Meanwhile, Post() (there is no Send() equivalent) simply works like this:

同时, Post() (没有Send()等效项)只是这样工作:

_queue.Enqueue(message);
_sync.Release(1);

That's the meat of it. There are several variants that do awaitable operations and/or take CancellationTokens but they all do the same thing as the above effectively.

这就是它的实质。 有几种变体可以执行等待的操作和/或采用CancellationToken但是它们都有效地完成与上述相同的操作。

等待和同步上下文 (Await and SynchronizationContext)

The await language feature typically generates code for you that uses the thread's synchronization context. Every time an await is found, a new state for the state machine it builds out of your method is created so that it can suspend the execution of the method. The method becomes restartable, and works very similarly to how C# iterators and yield work in terms of how it modifies and morphs your code. The problem is that your method is often "restarted" after the await on a different thread, due to being hooked into device I/O callbacks or being "awoken"/unsuspended by another OS thread. What you need is seamless transition of your code back to the original thread and that's exactly what await provides. It uses the thread's current SynchronizationContext to run the restarted method on the thread the routine was originally called from, using either Post() or Send(). This is why it's important to set the SynchronizationContext especially if you're using async/await and you need a custom one, like the one above.

await语言功能通常会使用线程的同步上下文为您生成代码。 每次找到await状态时,都会为它从您的方法构建的状态机创建一个新状态,以便它可以挂起该方法的执行。 该方法可重新启动,并且在修改和变形代码方面,其工作方式与C#迭代器和yield工作方式非常相似。 问题在于,由于挂接到设备I / O回调中或被另一个OS线程“唤醒” /未挂起,您的方法通常在不同线程上await之后“重新启动”。 您需要的是将代码无缝过渡回原始线程,而这正是await提供的。 它使用线程的当前SynchronizationContext在最初从其调用例程的线程上使用Post()Send()运行重新启动的方法。 这就是为什么设置SynchronizationContext非常重要的原因,特别是在使用async / await并且需要自定义变量的情况下,例如上面的一个。

However, if you configure the Task by using ConfigureAwait(false) it will override the typical behavior, and the current synchronization context will not be used, making the behavior the same as it would be when no synchronization context is set.

但是,如果使用ConfigureAwait(false)配置Task ,它将覆盖典型行为,并且将不使用当前同步上下文,从而使行为与未设置同步上下文时的行为相同。

翻译自: https://www.codeproject.com/Articles/5274751/Understanding-the-SynchronizationContext-in-NET-wi

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值