• C#中的线程(四)高级话题


    C#中的线程(四)高级话题

     

    Keywords:C# 线程
    Source:http://www.albahari.com/threading/
    Author: Joe Albahari
    Translator: Swanky Wu
    Published: http://www.cnblogs.com/txw1958/
    Download:http://www.albahari.info/threading/threading.pdf

    第四部分:高级话题

    非阻止同步

    早些时候,我们讨论了非常简单的赋值和 更新一个字段时需要使用同步的例子。尽管总是能 满足所需,但是一个排它锁意味着某个线程必须被阻止 ,就连累到系统开销和执行时间的不确定性。.NET framework 非阻止同步结构完成一些简单操作而不用阻止,暂停或等待。它涉及到如何使用 严格地原子操作,告诉编译器用 "volatile" 读和写的语法,有时候这种方式要比用锁还要简单。

    原子和互锁

    如果一个语句执行一个单独不可分割的指令,那么它是原子的。 严格的原子操作排除了任何抢占的可能性。在C#中,一个简单的读操作或给一个少于等与32位的字段赋值 是原子操作(假设为32位CPU)。更大的字段的操作都是非原子的,以及大于一个的读/写操作的组合:

    class Atomicity {
      static int x, y;
      static long z;
      
      static void Test() {
        long myLocal;
        x = 3;             // 原子的
        z = 3;             // 非原子的 (z 是 64 位)
        myLocal = z;       // 非原子的 (z 是 64 位)
        y += x;            // 非原子的 (读和写的操作)
        x++;               // 非原子的 (读和写的操作)
      }
    }

    在32位的计算机上读和写64位字段是非原子的是因为2个不同的32位的存储单元是息息相关的。如果线程A读一个64位的值, 而另一个线程B正在更新它,线程A会最后得到一个按位组合的老值和新值的结合体。

    x++这样的一元运算符需要首先读变量,然后处理它,再写回值给它。考虑下面的类:

    class ThreadUnsafe {
      static int x = 1000;
      static void Go () { for (int i = 0; i < 100; i++) x--; }
    }

    你可能会期待如果10个线程并行运行Go,然后x最后得到0。 但这并得不到保证,因为一个线程抢占了另一个正在检索x的值,或减少它,或写回它(导致一个过期的值被写入)。

    解决这个问题的一个方式就是在语句周围套上lock 语句。锁定,实际上是模拟原子操作。 Interlocked类提供了一个简单快速的简单原子锁的方案:

    class Program {
      static long sum;
     
      static void Main() {                                            // sum
     
        // 简单地增/减操作:
        Interlocked.Increment (ref sum);                              // 1
        Interlocked.Decrement (ref sum);                              // 0
     
        // 加减一个值:
        Interlocked.Add (ref sum, 3);                                 // 3
     
        // 读一个64位字段:
        Console.WriteLine (Interlocked.Read (ref sum));               // 3
     
        // 当正在读之前的值时同时写一个64位的值:
        // (当正在更新sum为10的时候,这里打印的是3)
        Console.WriteLine (Interlocked.Exchange (ref sum, 10));       // 10
     
        // 更新一个字段仅当它符合一个特定的值时(10):
        Interlocked.CompareExchange (ref sum, 123, 10);               // 123
      }
    }

    使用 Interlocked比用lock更有效,因为它从不阻止也没有临时操作线程带来的系统开销。

    Interlocked也对跨多个进程有效,这与只能在当前进程中跨线程的lock形成鲜明的对比。一个例子就是这对读和写共享内存是非常有用的。

    内存屏障和易变(Volatility)

    考虑这个类:

    class Unsafe {
      static bool endIsNigh, repented;
     
      static void Main() {
        new Thread (Wait).Start();        // Start up the spinning waiter
        Thread.Sleep (1000);              // Give it a second to warm up!
        repented = true;
        endIsNigh = true;
        Console.WriteLine ("Going...");
      }
      
      static void Wait() {
        while (!endIsNigh);               // Spin until endIsNigh
        Console.WriteLine ("Gone, " + repented);
      }
    }

    这儿有个问题:是否能有效地将"Going..." 和 "Gone"剥离开,换言之,是否有可能endIsNigh被设置 为true后,Wait方法仍然在执行while循环?此外,是否有可能Wait 方法输出"Gone, false"?

    这2个问题的答案,理论上是肯定的:在一个多处理器的计算机,如果线程协调程序将这2个线程分配给不同的CPU,repented 和endIsNigh字段可以被缓存到CPU寄存器中来提升性能,在它们的更新值被写回内存之前有可能延迟,当CPU寄存器被写回 到内存时,它没必要按原来的顺序进行更新。

    这个缓存过程可以用静态方法Thread.VolatileRead 和 Thread.VolatileWrite 来包围住来读和写这些字段。VolatileRead意味着“读最近的值”,VolatileWrite意味着 “立即写入内存”。相同的功能可以用volatile修饰符更优雅的实现:

    class ThreadSafe {
      // Always use volatile read/write semantics:
      volatile static bool endIsNigh, repented;
      ...
     

    如果volatile在 VolatileRead 和 VolatileWrite 方法之前使用,你可以简单地想象这个条款:“不要线程缓存(thread-cache)这个字段!”。

    相同的效果可以通过用lock语句包围住 repented 和 endIsNigh来实现。锁定的副作用(有意的)是引起了内存屏障——一个保证被用于lock中的易变字段不超出lock语句的范围。换言之,字段在进入锁之前被刷新(volatile read),在离开锁时被写入内存(volatile write)。

    在我们需要以原子方式进入字段end 和 endIsNigh时,lock是必要的,比如运行像这样的事情:

    lock (locker) { if (endIsNigh) repented = true; }

    当一个字段在一个循环中被使用多次,lock可能更是可取的方案。尽管一个volatile read/write在性能上击败了一个锁, 但不代表数千个volatile read/write操作能击败一把锁。

    易变方式仅适合于基本的整型(和不安全的指针),其它的类型不缓存在CPU寄存器上,也不能用 volatile关键字 声明。易变的读和写的语法自动地适合当字段通过Interlocked类访问的时候。

    如果你要保证多线程中被lock语句包住的字段总是可以进行存取,那么volatile 和 Interlocked便是多余的了。

    Wait 和 Pulse

    早些时候我们讨论了事件等待句柄 ——一个线程被阻止 直到它收到另一个发来的信号的简单信号机制。

    一个更强大的信号机制被Monitor类通过两个静态方法Wait 和 Pulse 提供。原理是你自己写一个使用自定义的标志和字段的信号逻辑(与lock语句协作),然后传入Wait 和 Pulse命令减轻CPU的轮询。优点是 仅仅使用低级别的 Wait, Pulse 和lock语句就能达到AutoResetEventManualResetEvent 和 Semaphore的功能,也包括WaitHandle'的静态方法 WaitAll 和 WaitAny。此外 Wait 和 Pulse经得起各种情况的考验,而所有的等待句柄是复杂的挑战。

    Wait 和 Pulse的一个问题是它们劣质的文档——尤其是解释它们实现的理由。更糟糕的是,Wait 和 Pulse非常讨厌不懂它的人:如果你在不完全明白的情况下调用了它们,它们将让你因找到而高兴并带来苦恼。幸运地是 有个简单的模式,在任何情况下你可以遵照这个提够了“自动防故障”的模式。

    Wait 和 Pulse 的定义

    Wait 和 Pulse的目标是提供一种简单的信号模式: Wait阻止直到收到其它线程的通知;Pulse提供了这个通知。

    为了信号系统正常工作,Wait必须在Pulse之前执行。如果 Pulse先执行了,它的pulse就会丢失,之后的wait必须等待一个新的pulse,否则它将永远被阻止。这和AutoResetEvent不同,AutoResetEvent的Set方法有一种“锁存”效果,当它先于WaitOne调用时也同样有效。

    在调用Wait 或 Pulse 的时候,你必须定义个同步对象 ,两个线程使用相同的对象,它们才能彼此发信号。在调用Wait 或 Pulse之前同步对象 必须被lock

    例如:如果x如此被声明:

    class Test {
      // 任何引用类型对象都可以作为同步对象
      object x = new object();
    }

    然后在进入Monitor.Wait前的代码:

    lock (x) Monitor.Wait (x);

    下面的代码释放了被阻止的线程(稍后在另一个线程上执行):

    lock (x) Monitor.Pulse (x);

    切换锁

    为了完成工作,在等待的时候Monitor.Wait临时的释放或切换当前的锁,所以另一个 线程(比如执行Pulse的这个)可以获得它。Wait方法可以被想象扩充为下面的伪代码。

    Monitor.Exit (x);             // 释放锁
    等待到x发的信号后
    Monitor.Enter (x);            // 收回锁

    因此一个Wait阻止两次:一次是等待信号,另一次是重新获取排它锁。这也意味着 Pulse本身不同完全解锁:只有当用Pulse发信号的线程退出它的锁语句的时候 ,等待的线程实际上才能继续运行。

    Wait的锁切换对 嵌套锁也是有效的, 如果Wait在两个嵌套的lock语句中被调用:

    lock (x)
      lock (x)
        Monitor.Wait (x);

    那么 Wait逻辑上展开如下:

    Monitor.Exit (x); Monitor.Exit (x);    // Exit两次来释放锁
    wait for a pulse on x
    Monitor.Enter (x); Monitor.Enter (x);  //还原之前的排它锁

    与普通锁结构一致,只有在第一次调用Monitor.Enter时提供了阻止的时机。

    为什么要阻止?

    为什么Wait 和 Pulse 被设计成只有在锁内才能工作呢?最主要的理由是Wait能够被有条件的调用——而不损害线程安全。来个例子说明,设想我们要只有在bool字段available为false时调用Wait,下面的代码是线程安全的:

    lock (x) {
      if (!available) Monitor.Wait (x);
      available = false;
    }

    几个线程并行运行这段代码,没有哪个可以在检查available字段和调用Monitor.Wait之间 抢占了另一个。这两个语句是有效的原子操作,一个相应的通告程序也是同样地线程安全的:

    lock (x)
      if (!available) {
        available = true;
        Monitor.Pulse (x);
      }

    定义超时

    在调用Wait时可以定义一个超时参数,可以是毫秒或TimeSpan值。 如果超时发生了,Wait 将返回false。超时仅用于“等待”阶段(等待信号pulse):超时的Wait 仍然继续执行以便重新得到锁,而不管花费了多长时间。例如:

    lock (x) {
      if (!Monitor.Wait (x, TimeSpan.FromSeconds (10)))
        Console.WriteLine ("Couldn't wait!");
      Console.WriteLine ("But hey, I still have the lock on x!");
    }

    这性能的理论基础是有一个良好设计的Wait/Pulse的程序, 调用 Wait 和 Pulse的对象只是暂时地被锁定,所以重新获得锁应当是一个 极短时间的操作。

    脉冲和确认 (Pulsing and acknowledgement)

    Monitor.Pulse的一个重要特性是它以异步方式执行,意味着 它本身不以任何方式暂定或阻止。如果另一个线程在等待脉冲对象,在它被通知的时候,脉冲本身没有效果而被悄悄地忽略。

    Pulse 提供了单向通信:一个脉冲的线程给等待线程发信号,没有内部的确认机制。Pulse不返回 值来指明它的信号是否被收到了。此外,当一个提示脉冲并释放了它的锁,不保证一个符合要求的等待线程能马上进入它的生命周期。 在线程调度程序的判断上,可能存在任意的延迟,在两个线程都没有锁的期间。这就难以知道等待线程时候已确切的重新开始了,除非 等待线程会明确确认,比如通过一个自定义的标志位。

    如果一定要实现可靠的确认,经常通过在Pulse 和 Wait间互为作用的一个标志位与另一个协同完成。

    从一个没有自定义的确认机制的工作线程中,依靠即时的动作会“弄乱”Pulse 和 Wait,你会输掉的!

    等待队列和PulseAll

    当多于一个线程同时Wait相同的对象——也就是在同步对象上形成了“等待队列”(这和有权访问某个锁的“就绪队列”明显不同)。 每个Pulse然后释放在等待队列头上的单个线程,所以它可以进入就绪队列并重新得到锁。可以把这个过程想象成 一个停车场:你排在收费处的第一个来确认你的票(等待队列);你再一次排队在格挡门前来被放掉(就绪队列)。

    图 2: 等待队列 vs. 就绪队列

    队列结构有它固有的顺序,但对于Wait/Pulse程序来说通常是不重要的; 在这些场合中它容易被想成一个等待线程“池”,每次pulse都从池中释放了一个等待线程。

    Monitor也提供了PulseAll方法在一刹那之间通过这等待线程释放整个队列,或池。 已pulse的线程不会在同一时刻同时开始执行,而是在一个顺序队列中,每次Wait语句试图重新回去那个相同的锁。 实际上,PulseAll将线程从等待队列移到就绪队列中,所以它们可以以顺序的方式继续执行。

    如何使用 Pulse 和 Wait

    这展示我们如何开始,设想有两条规则:

    • 同步结构仅在也被称为Monitor.Enter 和 Monitor.Exit的lock语句中。
    • CPU轮询上没有限制!

    有了它们在脑子里,让我们做一个简单的例子:一个工作线程暂停直到收到主线程的发的信号:

    class SimpleWaitPulse {
      bool go;
      object locker = new object();
     
      void Work() {
        Console.Write ("Waiting... ");
        lock (locker) {                        // 开始轮询!
          while (!go) {
            // 释放锁,以让其它线程可以更改go标志
            Monitor.Exit (locker); 
            // 收回锁,以便我们可以在循环中重测go的值
            Monitor.Enter (locker);
          }
        }
        Console.WriteLine ("Notified!");
      }
     
      void Notify()// 从另一个线程调用
      {
        lock (locker) {
          Console.Write ("Notifying... ");
          go = true;
        }
      }
    }

    让事情可以运转的主方法:

    static void Main() {
      SimpleWaitPulse test = new SimpleWaitPulse();
     
      // 在单独的线程中运行Work方法
      new Thread (test.Work).Start();            // "Waiting..."
     
      // 暂停一秒,然后通过我们的主线程通知工作线程:
      Thread.Sleep (1000);
      test.Notify();                 // "Notifying... Notified!"
    }

    我门所轮询的Work方法——使用循环挥霍着CPU的时间直到go标志变为true! 在这个循环中我们必须切换锁——释放和重新得到它通过Monitor的 Exit 和 Enter 方法——以便另一个运行Notify方法的线程可以修改 go标志。共享的go字段必须总是可以在一个锁内访问,来避免易变问题。(要记得所有的同步结构 ,比如volatile关键字,在这个阶段的设计超出范围了!)

    下一步是去运行它并测试它是否可以工作,下面是是测试 Main方法的输出结果:

    Waiting... (pause) Notifying... Notified!

    现在我们来介绍Wait 和 Pulse,我们由:

    • Monitor.Wait替换切换锁(Monitor.Exit和Monitor.Enter)
    • 在阻止条件改变后,插入调用Monitor.Pulse(比如go字段被修改)

    下面是更新后的类,Console语句被省略了:

    class SimpleWaitPulse {
      bool go;
      object locker = new object();
     
      void Work() {
        lock (locker)
          while (!go) Monitor.Wait (locker);
      }
     
      void Notify() {
        lock (locker) {
          go = true;
          Monitor.Pulse (locker);
        }
      }
    }

    这个类与之前的表现一致,但没有CPU的轮询,Wait命令立即执行我们移除的代码——Monitor.Exit 和之后的 Monitor.Enter,但中间由个扩充步骤:当锁被释放,它等待另一个线程调用Pulse。提示方法完成这个功能,在设置go 为true后,工作就做完了。

    Pulse 和 Wait 的归纳

    我们现在来扩充这个模式。在之前的例子中,我们的阻止条件以一个bool字段——go标志来实现 。我们可以换种设定,需要一个额外的标志来表明等待线程它是就绪或完成了。如果根据我们的推断,将有很多字段来实现很多的阻止条件 ,程序可以被归纳为下面的伪代码(以轮询模式):

    class X {
      阻止的字段:  一个或多个实现阻止条件的字段,比如
       bool go;   bool ready;   int semaphoreCount;   Queue <Task> consumerQ...
     
      object locker = new object();     // 保护上述所有字段!
     
      ... 某个方法 {
        ... 当任何我们想要根据阻止字段来进行阻止时:
        lock (locker)
          while (! 我想用的阻止字段 ) {
            // 给其它的线程改变阻止的字段!
            Monitor.Exit (locker);
            Monitor.Enter (locker);
          }
     
        ... 无论何时我想要变更一个或多个阻止字段时:
        lock (locker) { 变更阻止字段 }
      }
    }

    就像之前做的一样,我们将之应用到 Pulse 和 Wait 上:

    • 在等待循环中,用Monitor.Wait替换锁切换
    • 无论何时阻止条件被改变了,在释放锁之前调用Pulse

    这是更新后的伪代码:

    Wait/Pulse 样板 #1: Wait/Pulse 基础用法

    class X {
      < 阻止字段 ... >
      object locker = new object();

      ... 某个方法 {
        ...
        ... 当任何我们想要根据阻止字段来进行阻止时:
        lock (locker)
          while (! 我想用的阻止字段 )
            Monitor.Wait (locker);

        ... 无论何时我想要变更一个或多个阻止字段时:
        lock (locker) {
          alter 变更阻止字段
          Monitor.Pulse (locker);
        }     
      }
    }

    这提供了一个在使用Wait and Pulse时的健壮模式。这有些对这个模式的关键特征:

    • 使用自定义字段来实现阻止条件(也可以不用 Wait 和 Pulse,虽然会轮询)
    • Wait总是在while循环内调用来检查条件(它本身又在lock中)
    • 一个单一的同步对象(在上面的例子里是locker),被用于所有的Wait 和 Pulse, 并且来保护访问所有实现阻止条件的对象。
    • 锁的掌控只是暂时地

    这种模式最重要的是pulse不强迫等待线程继续执行,代替为它通知等待线程有些东西改变了,建议它重新检查它的阻止条件,等待线程然后决定是否 需要它该继续进行(通过另一个while循环),而不是脉冲发生器。这个方式的好处是它允许复杂的阻止条件,而没有复杂的 同步逻辑。

    这个模式的另一个好处是对丢失的脉冲具有抗扰性。当Pulse在Wait之前被调用的时候,脉冲发生丢失——可能归咎于提示线程和等待线程的竞争。当时因为在这个模式里一个脉冲意味着“重新检查你的阻止条件”(而不是“继续运行”),早的脉冲可以被 安全的忽略,因为阻止条件总是在调用Wait之前检查,这要感谢while语句。

    依托这种设计,你可以定义多个阻止字段,让它们参与到多个阻止条件之中,并且在这期间只需要用一个单一的同步对象(在我们例子里是locker)。这经常优于在lock, Pulse 和 Wait上有各自的同步对象,因为这样有效的避免了死锁。此外使用了同步锁定对象,所有阻止字段被以单元模式读和写,就避免了微秒的原子错误。这是一个好主意,但是,不要试图必要的区域之外用同步对象(这可以用private声明同步对象来实现,对阻止字段来说也是一样)。

    生产者/消费者 队列

    一个普通的Wait/Pulse程序是一个生产消费队列——我们之前用 AutoResetEvent来写的一种结构。 生产者入队任务(通常在主线程中),同时一个或多个消费者运行工作线程来一个接一个地摘掉和执行任务。

    在这个例子中我们将用字符串来表示任务,我们的任务队列看起来会像这样:

    Queue<string> taskQ = new Queue<string>();

    因为队列用于多线程,我们必须用lock来包住所有读写队列的语句。这是如何入队任务:

    lock (locker) {
      taskQ.Enqueue ("my task");
      Monitor.PulseAll (locker);   // 我们改变阻止条件
    }

    因为我们潜在修改了阻止条件,我们必须脉冲。我们调用PulseAll代替Pulse,因为我们将允许 多个消费者。多于一个线程可能正在等待。

    我们要让工作线程阻止当它没有什么可做的时候,换句话说就是队列里没有条目了。因此我们的阻止条件是 taskQ.Count==0。这是实现了一个等待语句:

    lock (locker)
      while (taskQ.Count == 0) Monitor.Wait (locker);

    下一步是工作线程出列任务并执行它:

    lock (locker)
      while (taskQ.Count == 0) Monitor.Wait (locker);
     
    string task;
    lock (locker)
      task = taskQ.Dequeue();

    但是这个逻辑是非线程安全的:我们以一个在旧的信息上的出列为判定基础——从之前的锁结构获得的。考虑当 我们并行地打开2个消费者线程,对一个已在队列上的单一条目,可能没有线程会进入 while循环来阻止——当然他们在队列中都看到这个单一的条目的时候。它们都试图出列相同的条目,在第二个实例中将抛出异常!为了修复这个问题,我们简单地将lock扩大一点 直到我们完成与队列的结合:

    string task;
    lock (locker) {
      while (taskQ.Count == 0) Monitor.Wait (locker);
      task = taskQ.Dequeue();
    }

    (我们不需要在出列之后调用Pulse,因为没有消费者在队列有较少的的条目时可以永远处于非阻止状态。)

    一旦任务出列后,没必要在保持锁了,这时就释放它以允许消费者去执行一个可能耗时的任务,而没必要去阻止其它线程。

    这里是完整的程序。与AutoResetEvent 版本 一样,我们入列一个null任务来通知消费者退出(在完成所有任务之后)。因为我们支持多个消费者,我们必须为每个消费者入列一个null任务来关闭队列:

    Wait/Pulse 样板 #2: 生产者/消费者 队列

    using System;
    using System.Threading;
    using System.Collections.Generic;

    public class TaskQueue : IDisposable {
      object locker = new object();
      Thread[] workers;
      Queue<string> taskQ = new Queue<string>();

      public TaskQueue (int workerCount) {
        workers = new Thread [workerCount];

        // Create and start a separate thread for each worker
        for (int i = 0; i < workerCount; i++)
          (workers [i] = new Thread (Consume)).Start();
      }

      public void Dispose() {
        // Enqueue one null task per worker to make each exit.
        foreach (Thread worker in workers) EnqueueTask (null);
        foreach (Thread worker in workers) worker.Join();
      }

      public void EnqueueTask (string task) {
        lock (locker) {
          taskQ.Enqueue (task);
          Monitor.PulseAll (locker);
        }
      }

      void Consume() {
        while (true) {
          string task;
          lock (locker) {
            while (taskQ.Count == 0) Monitor.Wait (locker);
            task = taskQ.Dequeue();
          }
          if (task == null) return;         // This signals our exit
          Console.Write (task);
          Thread.Sleep (1000);              // Simulate time-consuming task
        }
      }
    }

    这是一个开始任务队列的主方法 ,定义了两个并发的消费者线程,然后在两个消费者之间入列10个任务:

      static void Main() {
        using (TaskQueue q = new TaskQueue (2)) {
          for (int i = 0; i < 10; i++)
            q.EnqueueTask (" Task" + i);
     
          Console.WriteLine ("Enqueued 10 tasks");
          Console.WriteLine ("Waiting for tasks to complete...");
        }
        //使用TaskQueue的Dispose方法退出
        //在所有的任务完成之后,它关闭了消费者
        Console.WriteLine ("
    All tasks done!");
      }

    Enqueued 10 tasks
    Waiting for tasks to complete...
     Task1 Task0 (pause...) Task2 Task3 (pause...) Task4 Task5 (pause...)
     Task6 Task7 (pause...) Task8 Task9 (pause...) 
    All tasks done!

    与我们的设计模式一致,如果我们移除了PulseAll,并用Wait和切换锁替换它,我们将得到相同的输出结果。

    节省脉冲开销

    让我们回顾一下生产者入队一个任务:

    lock (locker) {
      taskQ.Enqueue (task);
      Monitor.PulseAll (locker);
    }

    严格地来讲,在只有空闲的被阻止的工作线程时,我们可以节省pulse:

    lock (locker) {
      taskQ.Enqueue (task);
      if (taskQ.Count <= workers.Length) Monitor.PulseAll (locker);
    }

    我们节省了一点点,可是因为脉冲一般花费在微秒间,并招致繁忙的工作线程没有系统开销,因此它们总是被忽略了! 精简任何没必要的多线程逻辑代码是一个好的策略:仅仅为了一毫秒性能的节省而产生归咎于愚蠢错误的间歇的bug是一个沉重的代价! 为了证明这一点,这里引入了个间歇性的“坚持工作者”bug,它很可能规避最开始的测试(注意不同点):

    lock (locker) {
      taskQ.Enqueue (task);
      if (taskQ.Count < workers.Length) Monitor.PulseAll (locker);
    }

    脉冲无条件地从这种类型的bug保护我们。

    如果对Pulse有疑问,使用这种设计模式,你就很少会犯错了。

    Pulse 还是 PulseAll?

    这个例子中,进一步的pulse节约成本问题随之而来,在入列一个任务之后,我们可以用调用Pulse来代替 PulseAll,这不会破坏什么。

    让我们看看它们的不同:对于Pulse,最多一个线程会被唤醒(重新检查它的while-loop阻止条件); 对于PulseAll来说,所有的等待线程都被唤醒(并重新检查它们的阻止条件)。如果我们入列一个单一的任务 只有一个工作线程能够得到它,所以我们只需要使用一个Pulse唤醒一个工作线程。这就像有一个班级的孩子 ——如果仅仅只有一个冰激凌,没必要把他们都叫醒去排队得到它!

    在我们的例子中,我们仅仅使用了两个消费者线程,所以我们不会有什么获利。但是如果我们使用了10个消费者线程,使用 Pulse 代替PulseAll可以让我们可能微微获利。 这将意味着,我们每入列多个任务,我们必须Pulse多次。这可以在一个单独lock语句 中进行,像这样:

    lock (locker) {
      taskQ.Enqueue ("task 1");
      taskQ.Enqueue ("task 2");
      Monitor.Pulse (locker);    // "发两此信号 
      Monitor.Pulse (locker);    //  给等待线程"
    }

    其中一个Pulse的价值对于一个坚持工作的线程来说价值几乎为零。这也经常出现间歇性的bug,因为 它会突然出现仅仅在当一个消费线程处于Waiting状态时,因此你可以扩充之前的信条为“如果对Pulse有疑问”为“如果对PulseAll有疑问!”。

    对于这个规则的可能出现的异常一般是由于判断阻止条件是耗时的。

    使用等待超时

    有时候当非阻止条件发生时Pulse是不切实际或不可能的。 一个可能的例子就是阻止条件调用一个周期性查询数据库得到信息的方法。如果反应时间不是问题,解决方案就很简单:你可以定义一个 timeout在调用Wait的时候,如下:

    lock (locker) {
      while ( blocking condition )
        Monitor.Wait (locker, timeout);

    这就强迫阻止条件被重新检查,至少为超时定义一个正确的区间,就可以立刻接受一个pulse。阻止条件越简单,超时越容易造成高效率。

    同一系统工作的相当号如果pulse缺席,会归咎于程序的bug!所以值得在程序中的所有同步非常复杂的Wait上加上超时—— 这可作为复杂的pulse错误最终后备支持。这也提供了一定程度的bug抗绕度,如果程序被稍后修改了Pulse部分!

    竞争与确认

    我们说,我们想要一个信号,一个工作线程连续5次显示:Let's say we want a signal a worker five times in a row:

    class Race {
      static object locker = new object();
      static bool go;
     
      static void Main() {
        new Thread (SaySomething).Start();
     
        for (int i = 0; i < 5; i++) {
          lock (locker) { go = true; Monitor.Pulse (locker); }
        }
      }
     
      static void SaySomething() {
        for (int i = 0; i < 5; i++) {
          lock (locker) {
            while (!go) Monitor.Wait (locker);
            go = false;
          }
          Console.WriteLine ("Wassup?");
        }
      }
    }
     
    期待输出:

    Wassup?
    Wassup?
    Wassup?
    Wassup?
    Wassup?

    实际输出:

    Wassup?
     (终止)

    这个程序是有缺陷的:主线程中的for循环可以任意的执行它的5次迭代在工作线程还没得到锁的任何时候内,可能工作线程甚至还 没开始的时候!生产者/消费者的例子没有这个问题,因为主线程胜过工作线程,每个请求只会排队。但是在这个情况下,我们需要在工作线程仍然忙于之前的任务的时候,主线程阻止迭代。

    比较简单的解决方案是让主线程在每次循环后等待,直到go标志而被工作线程清除掉,这样就 需要工作线程在清除go标志后调用Pulse:

    class Acknowledged {
     static object locker = new object();
      static bool go;
     
      static void Main() {
        new Thread (SaySomething).Start();
     
        for (int i = 0; i < 5; i++) {
          lock (locker) { go = true; Monitor.Pulse (locker); }
          lock (locker) { while (go) Monitor.Wait (locker); }
        }
      }
     
      static void SaySomething() {
        for (int i = 0; i < 5; i++) {
          lock (locker) {
            while (!go) Monitor.Wait (locker);
            go = false; Monitor.Pulse (locker);   // Worker must Pulse
          }
          Console.WriteLine ("Wassup?");
        }
      }
    }

    Wassup? (重复了5次)

    这个程序的一个重要特性是工作线程,在执行可能潜在的耗时工作之前释放了它的锁(此处是发生在我们的Console.WriteLine处)。这就确保了当工作线程仍在执行它的任务时调用者不会被过分的阻止,因为它已经被发过信号了(并且只有在工作线程仍忙于之前的任务时才被阻止)。

    在这个例子中,只有一个线程(主线程)给工作线程发信号执行任务,如果多个线程一起发信号给工作线程—— 使用我们的主方法的逻辑——我们将出乱子的。两个发信号线程可能彼此按序执行下面的这行代码:

      lock (locker) { go = true; Monitor.Pulse (locker); }

    如果工作线程没有发生完成处理第一个的时候,导致第二个信号丢失。我们可以在这种情形下通过一对标记来让我们的设计更健壮些。 “ready”标记指示工作线程能够接受一个新任务;“go”标记来指示继续执行,就像之前的一样。 这与之前的执行相同的事情的使用两个AutoResetEvent的例子类似,除了更多的可扩充性。下面是模式,重分解了实例字段:

    Wait/Pulse 样板 #3: 双向信号

    public class Acknowledged {
      object locker = new object();
      bool ready;
      bool go;  

      public void NotifyWhenReady() {
        lock (locker) {
          // 等待 当工作线程已在忙之前的时
          while (!ready) Monitor.Wait (locker);
          ready = false;
          go = true;
          Monitor.PulseAll (locker);
        }
      }

      public void AcknowledgedWait() {    
        // 预示我们准备处理一个请求
        lock (locker) { ready = true; Monitor.Pulse (locker); }
          
        lock (locker) {
          while (!go) Monitor.Wait (locker);      // 等待 一个“go”信号
          go = false; Monitor.PulseAll (locker);  // 肯定信号(确认相应)
        }
          
        Console.WriteLine ("Wassup?");            // 执行任务
      }
    }

    为了证实,我们启动两个并发线程,每个将通知工作线程5次,期间,主线程将等待10次报告:

    public class Test {
      static Acknowledged a = new Acknowledged();
     
     static void Main() {
        new Thread (Notify5).Start();     // Run two concurrent
        new Thread (Notify5).Start();     // notifiers...
        Wait10();                         // ... and one waiter.
      }
     
      static void Notify5() {
        for (int i = 0; i < 5; i++)
          a.NotifyWhenReady();
      }
     
      static void Wait10() {
        for (int i = 0; i < 10; i++)
          a.AcknowledgedWait();
      }
    }

    Wassup?
    Wassup?
    Wassup?
     (重复10次)

    Notify方法中,当离开lock语句时ready标记被清除。 这是及其重要的:它保证了两个通告程序持续的发信号而不用重新检查标记。为了简单,我们也设置了go标记 并且调用PulseAll语句在相同的lock语句中——尽管我们也无妨吧这对语句放在 分离的锁中,没有什么不同的。

    模拟等待句柄

    你可能已经注意到了之前的例子里的一个模式:两个等待循环都有下面的结构:

    lock (locker) {
      while (!flag) Monitor.Wait (locker);
      flag = false;
     ...
    }

    flag 在另一个线程里被设置为true,这个作用就是模拟 AutoResetEvent。 如果我们忽略flag=false,我们就相当于得到了ManualResetEvent。使用一个整型字段,Pulse 和 Wait 也能被用于模拟Semaphore。 实际上唯一用Pulse 和 Wait不能模拟的等待句柄是Mutex,因为这个功能被lock提供。

    模拟跨多个等待句柄的工作的静态方法大多数情况下是很容易的。相当于在多个EventWaitHandle间调用WaitAll,无非是阻止条件囊括了所有用于标识用以代替等待句柄:

    lock (locker) {
      while (!flag1 && !flag2 && !flag3...) Monitor.Wait (locker);

    这特别有用,假设waitall是在大多数情况由于com遗留问题不可用。模拟WaitAny更容易了,大概只要把 &&操作符替换成||操作符就可以了。

    SignalAndWait 是需要技巧的。回想这个顺序发信号一个句柄而同时在同一个原子操作中等待另一个。 我们情形与分布式的数据库事务操作类似——我们需要双相确认(commit)!假定我们想要发信号 flagA同时 等待flagB,我们必须分开每个标识为2个,导致代码看起来像这样:

    lock (locker) {
      flagAphase1 = true;
      Monitor.Pulse (locker);
      while (!flagBphase1) Monitor.Wait (locker);
     
      flagAphase2 = true;
      Monitor.Pulse (locker);
      while (!flagBphase2) Monitor.Wait (locker);
    }

    多半附加"rollback"逻辑到取消flagAphase1,如果第一个Wait语句抛出异常 作为中断或终止的结果。这个方案使用等待句柄是多么的简单啊!真正原子操作的 Wait 和 Pulse,然而却是罕见的需求。

    等待汇集

    就像WaitHandle.SignalAndWait 可以用于汇集一对线程一样,Wait和 Pulse它们也可以。 接下来这个例子,我们要模拟两个ManualResetEvent(换言之,我们要定义两个布尔标识!)并且然后执行 彼此的Wait 和 Pulse,通过设置某个标识同时等待另一个。这个情形下我们在Wait 和 Pulse不需要真正的原子操作,所以我们 避免需要“双相确认”。当我们设置我们的标识为true,并且在相同的lock语句中进行等待,汇集就会工作了:

    class Rendezvous {
      static object locker = new object();
      static bool signal1, signal2;
     
      static void Main() {
        // Get each thread to sleep a random amount of time.
        Random r = new Random();
        new Thread (Mate).Start (r.Next (10000));
        Thread.Sleep (r.Next (10000));
     
        lock (locker) {
          signal1 = true;
          Monitor.Pulse (locker);
          while (!signal2) Monitor.Wait (locker);
        }
        Console.Write ("Mate! ");
      }
     
      // This is called via a ParameterizedThreadStart
      static void Mate (object delay) {
        Thread.Sleep ((int) delay);
        lock (locker) {
          signal2 = true;
          Monitor.Pulse (locker);
          while (!signal1) Monitor.Wait (locker);
        }
        Console.Write ("Mate! ");
      }
    }

    Mate! Mate! (几乎同时出现)

    Wait 和 Pulse vs. 等待句柄

    因为Wait 和 Pulse 是最灵活的同步结构 ,所以它们可以用于几乎任何情况下。尽管如此Wait Handles有 两个优势:

    • 他们有跨进程工作的能力
    • 它们更容易理解,并更难于被破坏

    加之,等待句柄是更适合共同使用,它们能通过方法的参数进行传递。在线程池中,这个技术非常值得使用。

    在性能方面,如果你遵从wait的设计模式,Wait 和 Pulse有轻微 的优势,如下:

    lock (locker)
      while ( blocking condition ) Monitor.Wait (locker);

    并且阻止条件在外部为设置为false。仅有的开销就是去掉锁(数十纳秒间),而调用WaitHandle.WaitOne要 花费几毫秒,当然这要保证锁是无竞争的锁。甚至简短的锁条件将太多了使事情完成;频繁的锁条件使等待句柄更快!

    鉴于不用的CPU,操作系统,CLR版本和程序逻辑潜在的变化;在任何情况下,几毫秒对于在Wait 语句之前的任何逻辑判定是不可靠的,用性能选择Wait 和 Pulse代替等待句柄,可能是不确定的理由, 反之亦然。

    明智的原则是使用等待句柄在那些有助于它自然地完成工作的特殊结构中,否则就选择使用Wait 和 Pulse。

    Suspend 和 Resume

    线程可以被明确的挂起和恢复通过Thread.Suspend 和 Thread.Resume 这个机制与之前讨论的阻止完全分离。它们两个是独立的和并发的执行的。

    一个线程可以挂起它本身或其它的线程,调用Suspend 导致线程暂时进入了 SuspendRequested状态,然后在达到无用单元收集的安全点之前,它进入Suspended状态。 从那时起,它只能通过另一个线程调用Resume方法恢复。Resume只对挂起的 线程有用,而不是阻止的线程。

    从.NET 2.0开始Suspend 和 Resume被不赞成使用了,因为任意挂起 起线程本身就是危险的。如果在安全权限评估期间挂起持有锁的线程,整个程序(或计算机)可能会死锁。这远比调用Abort危险——Abort依靠finally块中的代码,导致任何这样的锁被释放。

    但是,在当期的线程上安全地调用Suspend,这样做你可以实现一个简单的同步机制:在一个循环中使用工作线程, 执行一个任务,在它自己上调用Suspend,然后等待在另一个任务准备号之后通过主线程被恢复(“唤醒”)。难点 是判断工作线程是否被挂起了,考虑下面的代码:

    worker.NextTask = "MowTheLawn";
    if ((worker.ThreadState & ThreadState.Suspended) > 0)
      worker.Resume;
    else
      //我们不能调用Resume当线程正在运行的时候
      //代替以用一个标志来告诉工作线程:
      worker.AnotherTaskAwaits = true;

    这是可怕的非线程安全的,在工作线程向前推进或改变它状态的时候,代码可能抢占这五行的任意一点。尽管它可以工作, 但是和另一个方案——使用同步结构比如AutoResetEventMonitor.Wait比起来还是太复杂了。这就使Suspend 和Resume不在有任何用处了。

    不赞同使用的Suspend 和 Resume方法有两个模式:危险和无用!

    终止线程

    一个线程可以通过Abort方法被强制终止:

    class Abort {
      static void Main() {
        Thread t = new Thread (delegate() {while(true);});   // 永远轮询
        t.Start();
        Thread.Sleep (1000);        // 让它运行几秒...
        t.Abort();                  // 然后终止它
      }
    }

    线程在被终止之前立即进入AbortRequested 状态。如果它如预期一样终止了,它进入Stopped状态。调用者可以通过调用Join来等待这个过程完成:

    class Abort {
      static void Main() {
        Thread t = new Thread (delegate() { while (true); });
        Console.WriteLine (t.ThreadState);     // Unstarted
     
        t.Start();
        Thread.Sleep (1000);
        Console.WriteLine (t.ThreadState);     // Running
     
        t.Abort();
        Console.WriteLine (t.ThreadState);     // AbortRequested
     
        t.Join();
        Console.WriteLine (t.ThreadState);     // Stopped
      }
    }

    Abort引起ThreadAbortException 异常被抛出在目标线程上, 大多数情况下就使线程执行的那个时候。线程被终止可以选择处理异常,但是异常会自动在catch语句最后被重新抛出(来帮助保证线程确实如期望的结束了)。尽管如此,可能避免自动地重新抛出通过调用Thread.ResetAbort在catch语句块内。 线程然后重新进入Running 状态(由于它可能潜在被又一次终止)。在下面的例子中,工作线程每次从死恢复回来每当Abort试图终止的时候:

    class Terminator {
      static void Main() {
        Thread t = new Thread (Work);
        t.Start();
        Thread.Sleep (1000); t.Abort();
        Thread.Sleep (1000); t.Abort();
        Thread.Sleep (1000); t.Abort();
      }
     
      static void Work() {
        while (true) {
          try { while (true); }
          catch (ThreadAbortException) { Thread.ResetAbort(); }
          Console.WriteLine ("I will not die!");
        }
      }
    }

    ThreadAbortException被运行时处理过,导致它当没有处理时不会引起整个程序结束,而不像其它类型的线程

    Abort几乎对处于任何状态的线程都有效, running,blockedsuspended或 stopped。尽管挂起的线程会失败,但ThreadStateException会被抛出,这时在正调用的线程中, 异常终止不会踢开直到线程随后恢复,这演示了如何终止一个挂起的线程:

    try { suspendedThread.Abort(); }
    catch (ThreadStateException) { suspendedThread.Resume(); }
    //现在suspendedThread将被终止

    Thread.Abort的复杂因素

    假设一个被终止的线程没有调用ResetAbort,你可能期待它正确地并迅速地结束。但是争先它所发生 的那样,懂法规的线程可能驻留在死那行一段时间!有一点原因可能保持它延迟在AbortRequested状态:

    • 静态类的构造器执行一半是不能被终止的(免得可能破坏类对于程序域内的生命周期)
    • 所有的catch/finally语句块被尊重,不会在这期间终止
    • 如果线程正执行到非托管的代码时进行终止,执行会继续直到下次进入托管的代码中

    最后因素尤为麻烦,.NET framework本身提供了调用非托管的代码,有时候会持续很长的时间。 一个例子就是当使用网络或数据库类的时候。如果网资源或数据库死了或很慢的相应,有可能执行 完全的保留在非托管的代码中,至于多长时间依赖于类的实现。在这些情况,确定你不能用 Join 来终止线程——至少不能没有超时!

    终止纯.NET代码是很少有问题的,在try/finally或using语句组成合体来保证 正确时,终止发生应抛出ThreadAbortException异常。但是,即使那样,还是容易出错。比如考虑下面的代码:

    using (StreamWriter w = File.CreateText ("myfile.txt"))
      w.Write ("Abort-Safe?");

    C#using语句是简洁地语法操作,可以扩充为下面的:

    StreamWriter w;
    w = File.CreateText ("myfile.txt");
    try     { w.Write ("Abort-Safe"); }
    finally { w.Dispose();            }  

    有可能Abort引发在StreamWriter创建之后,但是 在try 之前,实际上根据挖掘IL,你可以看到也有可能它引发在 StreamWriter被创建和赋值给w之间:

    IL_0001:  ldstr      "myfile.txt"
    IL_0006:  call       class [mscorlib]System.IO.StreamWriter
                         [mscorlib]System.IO.File::CreateText(string)
    IL_000b:  stloc.0
    .try
    {
      ...

    无论那种,在finally中的Dispose方法,导致抛弃了打开文件的句柄 ——阻止了任何后来的试图创建myfile.txt 直到应用程序域结束。

    实际上,这个例子情况可能更复杂,因为Abort可能发证在实现File.CreateText中。 这引用了不透明的代码——我们没有的代码。幸运的是,.NET代码从来没有真正的不透明:我们可以再次滚动ILDASM,或更好的用 Lutz Roeder的 Reflector,找到framework的汇编,看到它调用 StreamWriter的构造器,有如下的逻辑:

    public StreamWriter (string path, bool append, ...)
    {
      ...
      ...
      Stream stream1 = StreamWriter.CreateFile (path, append);
      this.Init (stream1, ...);
    }

    这个构造函数里无处有try/catch语句,意味着如果Abort发生 在(非平凡)Init方法内,最近创建的流将被抛弃,绝不会关闭最近的文件句柄。 Nowhere in this constructor is there a try/catch block, meaning that if the Abort fires anywhere within the (non-trivial) Init method, the newly created stream will be abandoned, with no way of closing the underlying file handle.

    因为反编译每个请求CLR的调用是不现实的,这就出现了你如何着手写一个“有好终止”的方法。最普遍的是方式 根本就不要终止另一个线程——但除了增加一个自定义的布尔字段在工作线程类里,告诉它应该终止。 工作线程周期性检查这个标志,如果为true就温和地退出。令人讽刺的是,最温和的退出工作线程是 通过调用在它自己的线程上调用Abort——尽管明确地抛 出异常,也可以很好的工作。这确保了线程正常终止,在执行任何catch/finally语句 的时候——相当像从另一个线程调用终止,除了异常是在设计的地方抛出的:

    class ProLife {
      public static void Main() {
        RulyWorker w = new RulyWorker();
        Thread t = new Thread (w.Work);
        t.Start();
        Thread.Sleep (500);
        w.Abort();
      }
     
      public class RulyWorker {
        // The volatile keyword ensures abort is not cached by a thread
        volatile bool abort;   
     
        public void Abort() { abort = true; }
     
        public void Work() {
          while (true) {
            CheckAbort();
            // Do stuff...
            try      { OtherMethod(); }
            finally  { /* any required cleanup */ }
          }
        }
     
        void OtherMethod() {
          // Do stuff...
          CheckAbort();
        }
     
        void CheckAbort() { if (abort) Thread.CurrentThread.Abort(); }
      }
    }
     

    某个线程本身上调用终止是完全安全的。另一个是你的终止 使用了一段特别的代码,通常是用同步机制比如Wait Handle 或 Monitor.Wait。第三个终止线程是安全是是你随后终止线程所在的程序域或进程。

    结束应用程序域

    另一个方式实现有好的终止工作线程是通过终止持有它的应用程序域。在调用 Abort后,你简单地销毁应用程序域,因此释放了任何不正确引用的资源。

    严格地来讲,第一步——终止线程——是不必要的,因为当一个应用程序域卸载之后,所有期内线程都被终止了。 尽管如此,依赖这个特性的缺点是如果被终止的线程没有即时的退出(可能归咎于finally的代码,或之前讨论的理由) 应用程序域不会卸载,CannotUnloadAppDomainException异常将被抛出。因此最好明确 终止线程,然后在卸载应用程序域之前带着超时参数(受你所控)调用Join方法。

    在下面的例子里,工作线程访问一个死循环,使用非终止安全的File.CreateText方法创建并关闭一个文件。 主线程然后重复地开始和终止工作线程。它总是在一或两次迭代中失败,CreateText在获取了终止部分通过它的内部实现 机制,留下了一个被抛弃的打开文件的句柄:

    using System;
    using System.IO;
    using System.Threading;
     
    class Program {
      static void Main() {
        while (true) {
          Thread t = new Thread (Work);
          t.Start();
          Thread.Sleep (100);
          t.Abort();
          Console.WriteLine ("Aborted");
        }
      }
     
      static void Work() {
        while (true)
          using (StreamWriter w = File.CreateText ("myfile.txt")) { }
      }
    }

    Aborted
    Aborted
    IOException: The process cannot access the file 'myfile.txt' because it
    is being used by another process.

    下面是一个经修改类似的例子,工作线程在它自己的应用程序域中运行,应用程序域在线程被终止后被卸载掉。 它会永远的运行而没有错误,因为卸载应用程序域释放了被抛弃的文件句柄:

    class Program {
      static void Main (string [] args) {
        while (true) {
          AppDomain ad = AppDomain.CreateDomain ("worker");
          Thread t = new Thread (delegate() { ad.DoCallBack (Work); });
          t.Start();
          Thread.Sleep (100);
          t.Abort();
          if (!t.Join (2000)) {
            // 线程不会结束——这里我们可以放置一些操作,
            // 如果,实际上,我们不能做任何事,幸运地是
            // 这种情况,我们期待*线程*总是能结束。
          }
          AppDomain.Unload (ad);            // 卸载“受污染”的应用程序域
          Console.WriteLine ("Aborted");
        }
      }
     
      static void Work() {
        while (true)
          using (StreamWriter w = File.CreateText ("myfile.txt")) { }
      }
    }

    Aborted
    Aborted
    Aborted
    Aborted
    ...
    ...

    创建和结束一个应用程序域在线程的世界里是被分类到相关耗时的操作的(数毫秒),所以应该不定期的使用它,而不是把它放入 循环中。同时,实行分离,由应用程序域推出的另一项内容可以带来有利或不利,这取决于多线程程序展示出来的实现。 在单元测试方面,比如,在分离的应用程序域中运行线程,可以带来极大的好处。

    结束进程

    另一个线程结束的方式是通过它的父进程被终止掉。这方面的一个例子是当工作线程的IsBackground 属性被设置为true,当工作线程还在运行的时候主线结束了。后台线程不能 够保持应用程序存活,所以进程带着后台新城一起被终止。

    当一个线程由于它的父进程被终止了,它突然停止,不会有finally被执行。

    相同的情形在一个用户通过Windows任务管理器或一个进程被编程的方式通过Process.Kill时发生。

    服务项目 免费服务 图书购买 代码购买 技术咨询 定制开发 其他信息
    服务入口
    查看详情

    进入购买
    进入购买

    查看详情

    QQ:1354386063
    方倍看题
    特价代码
  • 相关阅读:
    C++ new的使用
    乘风破浪,遇见最美Windows 11之新微软商店(Microsoft Store)生态 微软商店(Microsoft Store)与开发者社区共同成长
    温故知新,Windows命令知多少,学习Command Shell和PowerShell
    乘风破浪,遇见最美Windows 11之现代Windows桌面应用开发 Inno Setup如何设置退出代码
    乘风破浪,遇见未来元宇宙(Metaverse)之微软和Meta战略合作,选择Azure作为战略云供应商,帮助加速人工智能的研究和开发
    乘风破浪,遇见未来元宇宙(Metaverse)之中金元宇宙研究系列,元宇宙系列之Web3.0:新范式开启互联网新阶段
    乘风破浪,遇见最美Windows 11之现代Windows开发运维 新认识微软开发工具箱(Microsoft Dev Box)并注册预览计划
    乘风破浪,遇见最美Windows 11之现代Windows桌面应用开发 微软跨平台UI框架.NET MAUI 6正式发布,一套代码面向多平台
    乘风破浪,遇见最美Windows 11之新微软商店(Microsoft Store)生态 宣布微软商店应用奖获奖者(Microsoft Store App Awards Winners)!
    乘风破浪,遇见最美Windows 11之现代Windows桌面应用开发 利用Windows创建大规模的下一代体验
  • 原文地址:https://www.cnblogs.com/zxtceq/p/5626329.html
Copyright © 2020-2023  润新知