C#多线程(11):线程等待

简介: C#多线程(11):线程等待

前面我们学习了很多用于线程管理的 类型,也学习了多种线程同步的使用方法,这一篇主要讲述线程等待相关的内容。


在笔者认真探究多线程前,只会new Thread;锁?Lock;线程等待?Thread.Sleep()


前面已经探究了创建线程的创建姿势和各种锁的使用,也学习了很多类型,也使用到了很多种等待方法,例如 Thread.Sleep()Thread.SpinWait();{某种锁}.WaitOne() 等。


这些等待会影响代码的算法逻辑和程序的性能,也有可能会造成死锁,在本篇我们将会慢慢探究线程中等待。


前言


volatile 关键字


volatile 关键字指示一个字段可以由多个同时执行的线程修改。


我们继续使用《C#多线程(3):原子操作》中的示例:

static void Main(string[] args)
        {
            for (int i = 0; i < 5; i++)
            {
                new Thread(AddOne).Start();
            }
            Thread.Sleep(TimeSpan.FromSeconds(5));
            Console.WriteLine("sum = " + sum);
            Console.ReadKey();
        }
        private static int sum = 0;
        public static void AddOne()
        {
            for (int i = 0; i < 100_0000; i++)
            {
                sum += 1;
            }
        }


运行后你会发现,结果不为 500_0000,而使用 Interlocked.Increment(ref sum);后,可以获得准确可靠的结果。


你试试再运行下面的示例:

static void Main(string[] args)
        {
            for (int i = 0; i < 5; i++)
            {
                new Thread(AddOne).Start();
            }
            Thread.Sleep(TimeSpan.FromSeconds(5));
            Console.WriteLine("sum = " + sum);
            Console.ReadKey();
        }
        private static volatile int sum = 0;
        public static void AddOne()
        {
            for (int i = 0; i < 100_0000; i++)
            {
                sum += 1;
            }
        }


你以为正常了?哈哈哈,并没有。

volatile 的作用在于读,保证了观察的顺序和写入的顺序一致,每次读取的都是最新的一个值;不会干扰写操作。


详情请点击:https://docs.microsoft.com/zh-cn/dotnet/csharp/language-reference/keywords/volatile

其原理解释:https://theburningmonk.com/2010/03/threading-understanding-the-volatile-modifier-in-csharp/

微信图片_20220504100922.png


三种常用等待


这三种等待分别是:

Thread.Sleep();
Thread.SpinWait();
Task.Delay();


Thread.Sleep(); 会阻塞线程,使得线程交出时间片,然后处于休眠状态,直至被重新唤醒;适合用于长时间的等待;


Thread.SpinWait(); 使用了自旋等待,等待过程中会进行一些的运算,线程不会休眠,用于微小的时间等待;长时间等待会影响性能;


Task.Delay(); 用于异步中的等待,异步的文章后面才写,这里先不理会;


这里我们还需要继续 SpinWait 和 SpinLock 这两个类型,最后再进行总结对照。


再说自旋和阻塞


前面我们学习过自旋和阻塞的区别,这里再来撸清楚一下。

线程等待有内核模式(Kernel Mode)和用户模式(User Model)。


因为只有操作系统才能控制线程的生命周期,因此使用 Thread.Sleep() 等方式阻塞线程,发生上下文切换,此种等待称为内核模式。


用户模式使线程等待,并不需要线程切换上下文,而是让线程通过执行一些无意义的运算,实现等待。也称为自旋。


SpinWait 结构


微软文档定义:为基于自旋的等待提供支持。


SpinWait 是结构体;Thread.SpinWait() 的原理就是 SpinWait 。

如果你想了解 Thread.SpinWait() 是怎么实现的,可以参考 https://www.tabsoverspaces.com/233735-how-is-thread-spinwait-actually-implemented


线程阻塞是会耗费上下文切换的,对于过短的线程等待,这种切换的代价会比较昂贵的。在我们前面的示例中,大量使用了 Thread.Sleep() 和各种类型的等待方法,这其实是不合理的。

SpinWait 则提供了更好的选择。


属性和方法


老规矩,先来看一下 SpinWait 常用的属性和方法。


属性:

属性 说明
Count 获取已对此实例调用 SpinOnce() 的次数。
NextSpinWillYield 获取对 SpinOnce() 的下一次调用是否将产生处理器,同时触发强制上下文切换。


方法:


方法 说明
Reset() 重置自旋计数器。
SpinOnce() 执行单一自旋。
SpinOnce(Int32) 执行单一自旋,并在达到最小旋转计数后调用 Sleep(Int32) 。
SpinUntil(Func) 在指定条件得到满足之前自旋。
SpinUntil(Func, Int32) 在指定条件得到满足或指定超时过期之前自旋。
SpinUntil(Func, TimeSpan) 在指定条件得到满足或指定超时过期之前自旋。


自旋示例


下面来实现一个让当前线程等待其它线程完成任务的功能。


其功能是开辟一个线程对 sum 进行 +1,当新的线程完成运算后,主线程才能继续运行。

class Program
    {
        static void Main(string[] args)
        {
            new Thread(DoWork).Start();
            // 等待上面的线程完成工作
            MySleep();
            Console.WriteLine("sum = " + sum);
            Console.ReadKey();
        }
        private static int sum = 0;
        private static void DoWork()
        {
            for (int i = 0; i < 1000_0000; i++)
            {
                sum++;
            }
            isCompleted = true;
        }
        // 自定义等待等待
        private static bool isCompleted = false;
        private static void MySleep()
        {
            int i = 0;
            while (!isCompleted)
            {
                i++;
            }
        }
    }


新的实现


我们改进上面的示例,修改 MySleep 方法,改成:

private static bool isCompleted = false;        
        private static void MySleep()
        {
            SpinWait wait = new SpinWait();
            while (!isCompleted)
            {
                wait.SpinOnce();
            }
        }


或者改成

private static bool isCompleted = false;        
        private static void MySleep()
        {
            SpinWait.SpinUntil(() => isCompleted);
        }


SpinLock 结构


微软文档:提供一个相互排斥锁基元,在该基元中,尝试获取锁的线程将在重复检查的循环中等待,直至该锁变为可用为止。


SpinLock 称为自旋锁,适合用在频繁争用而且等待时间较短的场景。主要特征是避免了阻塞,不出现昂贵的上下文切换。


笔者水平有限,关于 SpinLock ,可以参考 https://www.c-sharpcorner.com/UploadFile/1d42da/spinlock-class-in-threading-C-Sharp/

另外,还记得 Monitor 嘛?SpinLock 跟 Monitor 比较像噢~https://www.cnblogs.com/whuanle/p/12722853.html#2monitor

在《C#多线程(10:读写锁)》中,我们介绍了 ReaderWriterLock 和 ReaderWriterLockSlim ,而 ReaderWriterLockSlim 内部依赖于 SpinLock,并且比 ReaderWriterLock 快了三倍。


属性和方法


SpinLock 常用属性和方法如下:


属性:

属性 说明
IsHeld 获取锁当前是否已由任何线程占用。
IsHeldByCurrentThread 获取锁是否已由当前线程占用。
IsThreadOwnerTrackingEnabled 获取是否已为此实例启用了线程所有权跟踪。


方法:


方法 说明
Enter(Boolean) 采用可靠的方式获取锁,这样,即使在方法调用中发生异常的情况下,都能采用可靠的方式检查 lockTaken 以确定是否已获取锁。
Exit() 释放锁。
Exit(Boolean) 释放锁。
TryEnter(Boolean) 尝试采用可靠的方式获取锁,这样,即使在方法调用中发生异常的情况下,都能采用可靠的方式检查 lockTaken 以确定是否已获取锁。
TryEnter(Int32, Boolean) 尝试采用可靠的方式获取锁,这样,即使在方法调用中发生异常的情况下,都能采用可靠的方式检查 lockTaken 以确定是否已获取锁。
TryEnter(TimeSpan, Boolean) 尝试采用可靠的方式获取锁,这样,即使在方法调用中发生异常的情况下,都能采用可靠的方式检查 lockTaken 以确定是否已获取锁。


示例

SpinLock 的模板如下:

private static void DoWork()
        {
            SpinLock spinLock = new SpinLock();
            bool isGetLock = false;     // 是否已获得了锁
            try
            {
                spinLock.Enter(ref isGetLock);
                // 运算
            }
            finally
            {
                if (isGetLock)
                    spinLock.Exit();
            }
        }


这里就不写场景示例了。

需要注意的是, SpinLock 实例不能共享,也不能重复使用。


等待性能对比

大佬的文章,.NET 中的多种锁性能测试数据:http://kejser.org/synchronisation-in-net-part-3-spinlocks-and-interlocks/

这里我们简单测试一下阻塞和自旋的性能测试对比。


我们经常说,Thread.Sleep() 会发生上下文切换,出现比较大的性能损失。具体有多大呢?我们来测试一下。(以下运算都是在 Debug 下测试)

测试 Thread.Sleep(1)


private static void DoWork()
        {
            Stopwatch watch = new Stopwatch();
            watch.Start();
            for (int i = 0; i < 1_0000; i++)
            {
                Thread.Sleep(1);
            }
            watch.Stop();
            Console.WriteLine(watch.ElapsedMilliseconds);
        }


笔者机器测试,结果大约 20018。Thread.Sleep(1) 减去等待的时间 10000 毫秒,那么进行 10000 次上下文切换需要花费 10000 毫秒,约每次 1 毫秒。


上面示例改成:

for (int i = 0; i < 1_0000; i++)
            {
                Thread.Sleep(2);
            }

运算,发现结果为 30013,也说明了上下文切换,大约需要一毫秒。


改成 Thread.SpinWait(1000)

for (int i = 0; i < 100_0000; i++)
            {
                Thread.SpinWait(1000);
            }

结果为 28876,说明自旋 1000 次,大约需要 0.03 毫秒。

相关文章
|
1月前
|
存储 消息中间件 资源调度
C++ 多线程之初识多线程
这篇文章介绍了C++多线程的基本概念,包括进程和线程的定义、并发的实现方式,以及如何在C++中创建和管理线程,包括使用`std::thread`库、线程的join和detach方法,并通过示例代码展示了如何创建和使用多线程。
48 1
C++ 多线程之初识多线程
|
30天前
|
Java 开发者
在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口
【10月更文挑战第20天】在Java多线程编程中,创建线程的方法有两种:继承Thread类和实现Runnable接口。本文揭示了这两种方式的微妙差异和潜在陷阱,帮助你更好地理解和选择适合项目需求的线程创建方式。
19 3
|
30天前
|
Java 开发者
在Java多线程编程中,选择合适的线程创建方法至关重要
【10月更文挑战第20天】在Java多线程编程中,选择合适的线程创建方法至关重要。本文通过案例分析,探讨了继承Thread类和实现Runnable接口两种方法的优缺点及适用场景,帮助开发者做出明智的选择。
19 2
|
30天前
|
Java
Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口
【10月更文挑战第20天】《JAVA多线程深度解析:线程的创建之路》介绍了Java中多线程编程的基本概念和创建线程的两种主要方式:继承Thread类和实现Runnable接口。文章详细讲解了每种方式的实现方法、优缺点及适用场景,帮助读者更好地理解和掌握多线程编程技术,为复杂任务的高效处理奠定基础。
30 2
|
30天前
|
Java 开发者
Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点
【10月更文挑战第20天】Java多线程初学者指南:介绍通过继承Thread类与实现Runnable接口两种方式创建线程的方法及其优缺点,重点解析为何实现Runnable接口更具灵活性、资源共享及易于管理的优势。
34 1
|
30天前
|
安全 Java 开发者
Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用
本文深入解析了Java多线程中的`wait()`、`notify()`和`notifyAll()`方法,探讨了它们在实现线程间通信和同步中的关键作用。通过示例代码展示了如何正确使用这些方法,并分享了最佳实践,帮助开发者避免常见陷阱,提高多线程程序的稳定性和效率。
38 1
|
30天前
|
Java
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是线程间通信的核心机制。它们通过基于锁的方式,使线程在条件不满足时进入休眠状态,并在条件成立时被唤醒,从而有效解决数据一致性和同步问题。本文通过对比其他通信机制,展示了 `wait()` 和 `notify()` 的优势,并通过生产者-消费者模型的示例代码,详细说明了其使用方法和重要性。
25 1
|
1月前
|
存储 前端开发 C++
C++ 多线程之带返回值的线程处理函数
这篇文章介绍了在C++中使用`async`函数、`packaged_task`和`promise`三种方法来创建带返回值的线程处理函数。
48 6
|
1月前
|
存储 运维 NoSQL
Redis为什么最开始被设计成单线程而不是多线程
总之,Redis采用单线程设计是基于对系统特性的深刻洞察和权衡的结果。这种设计不仅保持了Redis的高性能,还确保了其代码的简洁性、可维护性以及部署的便捷性,使之成为众多应用场景下的首选数据存储解决方案。
41 1
|
1月前
|
C++
C++ 多线程之线程管理函数
这篇文章介绍了C++中多线程编程的几个关键函数,包括获取线程ID的`get_id()`,延时函数`sleep_for()`,线程让步函数`yield()`,以及阻塞线程直到指定时间的`sleep_until()`。
25 0
C++ 多线程之线程管理函数