【.NET】多线程:自动重置事件与手动重置事件的区别

在多线程编程中,如果每个线程的运行不是完全独立的。那么,一个线程执行到某个时刻需要知道其他线程发生了什么。嗯,这就是所谓线程同步。同步事件对象(XXXEvent)有两种行为:

1、等待。线程在此时会暂停运行,等待其他线程发出信号才继续(等你约);

2、发出信号。当前线程发出信号,其他正在等待线程收到信号后继续运行(我约你)。

从前,小明、小伟、小更、小红、小黄计划到野外去烤鱼吃。但他们只确定市郊东南方向的一片区域,并不能保证具体哪个地点适合烧烤。于是,他们商量好,大家同时从家里出发。小明离那里比较近,他先去考察一下;其他人到了东南郊后集合,等小明的消息。小明考察完毕,向大家群发消息说明选定的地点是F。最后大家继续前行,奔向F。

等待事件有好几个:

1、Mutex:互斥体。一次只能有一个线程获取到互斥体,其他线程只能等。占用互斥体的线程释放后,其他线程继续抢 Mutex。然后只有一个线程能抢到,其他线程继续等......

2、AutoResetEvent:自动事件,发出信号后立刻重置。

3、ManualResetEvent:手动事件,发出信号后不会立刻重置,得手动重置。

4、CountdownEvent:这个和上面两个差不多。但它会设定一个计数,线程发出信号时会减少计数。被阻止的线程要等到计数 <= 0 时才获得信号。

本次咱们讨论的重点是看看自动重置信号和手动重置信号之间有什么区别。

先看看自动重置的。

复制代码
internal class Program
{

    static AutoResetEvent theEvent = new(false);

    static void Main(string[] args)
    {
        // 启动三个线程
        ThreadPool.QueueUserWorkItem(DoWorking, "A");
        ThreadPool.QueueUserWorkItem(DoWorking, "B");
        ThreadPool.QueueUserWorkItem(DoWorking, "C");
        // 主线程监听键盘消息
        while(true)
        {
            var keyInfo = Console.ReadKey(true);
            // 看看是不是Y键
            if(keyInfo.Key == ConsoleKey.Y)
            {
                // 点亮信号
                theEvent.Set();
            }
            // 输出一行,方便判断一个循环
            Console.WriteLine("------------------------------");
        }
    }

    static void DoWorking(object? state)
    {
        while(true)
        {
            // 等待主线程的信号
            // 此线程会暂停
            theEvent.WaitOne();
            // 得到信号了,继续运行
            Console.WriteLine("{0}已收到通知", state);
        }
    }
}

这个例子创建了三个线程,这里我用的是线程池,把一个WaitCallback委托传给 QueueUserWorkItem 方法就可以在线程池中运行新线程。上面示例中绑定的方法是 DoWorking。

AutoResetEvent 类的构造函数传了一个 bool 值,它的作用是设置等待事件的初始状态:

1、如果为 true,表示事件初始状态为打开信号,这会使正在等的线程马上得到信号;

2、如果为 false,表示事件的初始状态为没有信号,正在等待的线程继续等。

按照咱们这个例子的实际情况,我们一开始应该让事件无状态,让后台的三个线程等待。主线程读取按键信息,如果按的是【Y】键,那么事件调用 Set 方法,打开信号。此时,等得花儿都谢了的三个线程会继续。我们运行一下,看看能否符合预期。

经测试,我们会发现:每次按【Y】后,三个线程中只有一个获得信号并继续,其他两个还在高速上堵车。 AutoResetEvent 的自动重置就是打开信号后又立马关闭,每次只让一个线程收到信号。所以,当咱们按一次【Y】键后,主线程发出了信号,又马上关闭。三个后台线程相互竞争,随机获得机会,结束等待并继续运行。

手动重置事件在打开信号后,信号会持续有效,直到调用 Reset 方法手动关闭信号。手动重置信号能让多个线程有足够的时间收到信号。

下面咱们把上面的示例改为使用 ManualResetEvent 类。

复制代码
internal class Program
{
    static ManualResetEvent theEvent = new(false);

    static void Main(string[] args)
    {
        // 启动三个线程
        ThreadPool.QueueUserWorkItem(DoWorking, "A");
        ThreadPool.QueueUserWorkItem(DoWorking, "B");
        ThreadPool.QueueUserWorkItem(DoWorking, "C");
        // 主线程监听键盘消息
        while(true)
        {
            var keyInfo = Console.ReadKey(true);
            // 看看是不是Y键
            if(keyInfo.Key == ConsoleKey.Y)
            {
                // 点亮信号
                theEvent.Set();

                // 持续一段时间后关闭信号
                Thread.Sleep(3);
                theEvent.Reset();
            }
            // 输出一行,方便判断一个循环
            Console.WriteLine("------------------------------");
        }
    }

    static void DoWorking(object? state)
    {
        while(true)
        {
            // 等待主线程的信号
            // 此线程会暂停
            theEvent.WaitOne();
            // 得到信号了,继续运行
            Console.WriteLine("{0}已收到通知", state);
        }
    }
}

然后运行程序,这一次按下【Y】键后,三个线程都能收到信号通知了。

你会发现,有些线程重复了多次,那是因为 DoWorking 方法里面是个死循环。当信号持续打开期间,三个线程都有机会收到信号,甚至会重复收到。

上面的东东纯属演示,实际使用的话不会这样设计。最好的方法是建一个列表对象,主线程接收到的按键字符存放到一个列表中,然后,后台线程不断地从列表中取出元素来处理。这样设计程序会更流畅。

复制代码
internal class Program
{
    #region 字段区域
    static Queue<char> keyChars = new();
    #endregion

    static void Main(string[] args)
    {
        // 启动三个线程
        ThreadPool.QueueUserWorkItem(DoSomething, "A");
        ThreadPool.QueueUserWorkItem(DoSomething, "B");
        ThreadPool.QueueUserWorkItem(DoSomething, "C");

        while(true)
        {
            // 读取键盘字符
            ConsoleKeyInfo info = Console.ReadKey(true);
            // 将字符放入队列
            keyChars.Enqueue(info.KeyChar);
        }
    }

    static void DoSomething(object? state)
    {
        while(true)
        {
            // 锁定
            Monitor.Enter(keyChars);
            if (keyChars.Count > 0)
            {
                // 取掉一个元素
                char c = keyChars.Dequeue();
                Console.WriteLine($"线程【{state}】获得字符:{c}");
            }
            // 解锁
            Monitor.Exit(keyChars);
        }
    }
}

这里我用泛型队列 Queue<T> 来存放键盘敲入的字符,DoSomething 方法将放入线程池中运行。在从队列中取出元素并处理时,一定要记得上锁。我用的是 Monitor 对象的静态方法来上锁和解锁,当然你可以用 lock 语句块。

复制代码
lock(keyChars)
{
    ......
}

如果不上锁,线程间在抢占资源时会导致不一致的状态。当A线程访问 keyChars.Count 属性时得到 1,还是 > 0 的,但在取出最后一个元素前,偏偏B线程动作快把最后一个元素拿走了。当A线程执行到 keyChars.Dequeue() 一句时,keyChars 队列中已经没有元素了,会发生错误。

主线程在 Enqueue 时并不需要锁定,因为元素送入队列只有一个线程在做,没人跟他抢资源,可以不锁定。

运行程序后,可以按字母、数字等按键来测试。毕竟像【F3】、【Ctrl】等按键获取到的是空白 char。

这样就顺畅很多了。