关于线程与多线程的入门常识,很多刚接触的同学,理解容易出现误差,这里我们分享一下多线程的一些入门常识。
什么是线程
线程是目前主流操作系统中,最小的执行单元。每个进程中至少包含一个线程。进程跟线程的区别在于,进程是一个资源管理器,他负责提供空间存放程序的数据,而线程是代码执行的单元,负责运算这些数据。在Linux 中,当进程中存在子线程时,主线程 return (也就是 main 函数 return)不会造成主进程退出,当所有的线程都结束才会主动退出进程。而在windows中,主线程return是会退出进程的,这是因为main函数是C语言的入口函数,不是系统的入口函数,系统入口函数在调用 main 函数之后,会接着调用销毁进程操作函数,把所有子进程都强行终结掉。正是这个原因,导致 windows 和 linux 的行为结果不同。
另外因为windows中销毁进程操作函数是放在在main函数后面的,它跟main函数同样处于主线程之中,如果是对主线程调用退出函数强行终结,那么销毁进程函数也不会被调用,进程也不会退出。
不要强行终止线程。
线程都是有创建函数和终止函数的,而线程的终止函数,如果对线程特性以及系统特性没有十分深入的理解,千万不要乱用终结线程函数。因为强行终止线程会引起不可控的后果。比如写文件写到一半突然间用户终止了操作,如果这个时候直接把线程终止掉,很容易损坏文件,因为此时文件可能只写到了一半,此外还可能引起内存泄漏。当然宕机也是相当于强行终结线程,也会引起文件的损坏,但这是题外话,不做过多讨论。
终止线程会内存泄漏,是因为所谓终止线程,其实就是将线程对象从等待队列或阻塞队列中移除,然后释放线程对象,而你在线程对象中所申请的资源,是交由进程管理的,所以只有终止进程才会释放申请的内存,强行终结线程是不会释放内存的。
C语言中,退出线程最好的办法,就是利用 return 关键字。
就绪队列与阻塞队列
就绪队列是用来存放那些活跃状态的线程,系统充当一个消费者的角色,把就绪队列的线程对象轮流载入CPU核心运行,这就是我们为什么只有一个CPU,却能同时运行多个线程的原因。
阻塞队列用来存放挂起的线程,那些锁函数的阻塞效果,也是通过将线程对象放入阻塞队列实现的,挂起的线程被唤醒,就是把线程对象从阻塞队列移动到就绪队列。另外值得一提的是,虽然阻塞用的是队列数据结构,但是等待过程中阻塞队列可能会发生变化,所以你不能认为线程等待和唤醒是先进先出的。
线程上下文切换
我们知道一条线程在运行期间,必然是依附在某个CPU核心之上的,而每个线程都有自己的时间片,当时间片用完了或者线程产生中断,系统就会把当前CPU核心转而执行另外一条线程。
这个流程的具体细节是:系统从等待队列取出下一个要执行线程对象,如果与当前线程对象相同,则CPU核心继续执行当前线程(注意,这里相当于跳过了上下文切换的动作,节省了切换开销)。如果不一样,则系统把当前CPU核心的各个寄存器参数拷贝保存到当前线程中,然后当前线程释放执行权限,新的线程获取到CPU核心的执行权限后,将保存在线程对象的各个寄存器参数拷贝到当前CPU核心的寄存器中。如果原来的线程还没执行完毕,那么它会被重新放置到等待队列中。
上述这个动作,就是所谓线程上下文切换,当上下文切换完成,CPU核心继续执行线程的代码(也就是我们自己写的代码)。此时大家也发现了,线程上下文切换,根本不是执行我们自己写的代码,它确实浪费了CPU的时间,却又非做不可,那有办法减少线程上下文的切换吗?答案是有的,因为CPU只执行活跃的线程,所以我们尽量少创建线程,或者少一点创建活跃的线程,就可以减少上下文切换的动作。可以想象,假设等待队列只有一个节点,那么它来来回回,都是执行这个线程对象,就不会产生上下文切换!只要系统活跃的线程足够少,下一个等待执行的线程对象,很可能就是CPU核心当前正在执行的线程,如此便减少了上下文切换的负担。
CPU时间分片
假设有一台机器它是单个CPU核心,为什么它还能同时执行多个任务呢?其实这是系统CPU时间分片机制造成的假象。系统为每条线程分配了一个执行时间,当达到以下某一条件,系统就会把CPU执行的线程切换到下一条:
- 执行时间结束,系统切换到下一条线程。
- 执行时间未结束,但线程代码执行完毕,线程结束,系统切换到下一条线程。
- 执行时间未结束,但线程陷入阻塞,系统切换到下一条线程。
由于CPU计算非常快,而线程时间执行时间片都是毫秒级别的原因,所以令我们觉得每一条线程都独自占用了整个CPU,而事实上这些线程都是并行触发的,一个个排队有序地执行。
什么是响应,效率,性能
响应指的是某个函数或某个操作的的完成时间,假设界面上有一个按钮,点击按钮后会创建一个新的线程去执行备份操作:
DWORD WINAPI DoBackup(LPVOID pParam)
{
//备份操作,需要10秒
::Sleep(10 * 1000);
}
void OnButton() //点击按钮回调
{
//假设CreateThread需要 1毫秒
CreateThread(NULL, 0, DoBackup, NULL, NULL, NULL);
}
代码如上。那么我们可以得出结论, OnButton 函数的响应时间是 1毫秒,而 DoBackup 函数的响应时间是10秒。
效率,指的是操作总的完成时间,上面的备份操作完成时间就是 OnButton + DoBackup 的完成时间,就是 10.001秒。切记,效率是要将某个操作的所有线程的时间加起来,才是它的效率时间。假设服务器收到一条请求,然后由4条线程去处理这个请求,每条线程都是3秒后返回结果,那么我们可以说,操作响应完成时间为3秒,操作效率完成时间为12秒。
还有一个就是性能,很多同学容易把它和效率这个概念混淆。上面已经说过,效率是操作总的完成时间,而性能,指的是操作方案的效率对比。也就是说,你有两个或以上的方案进行比较的时候,才能说提升了性能。
日常中我们所说的高性能方案,其实指的是这个方案跟一些常见的方案对比,效率更高。但如果你解决这个问题的方案只有一个,这个时候根本就没有所谓的性能可言,因为你无法说出比较效果!
为什么要说的这么细?因为一些对多线程理解不深的同学,在开发一些性能要求较高的功能时,心里面总有这么一个想法:多开几个线程解决性能问题。
要知道多开线程,往往只能缩减响应的时间,不一定(是不一定, 不是不能)能够缩减效率时间的。线程过多甚至会把响应时间也拖垮!我们接下来进行更详细的讲解:
回顾上面的代码,提出以下两种方案:
方案一,保持上面的做法,在OnButton函数中开启新线程,花费10.001秒解决备份问题。
方案二,按钮回调中,不调用OnButton函数,直接调用 DoBackup 函数,花费 10 秒解决备份问题。
两个方案中,明显方案一的响应更快,而方案二的效率更高。而我们日常开发中,选择的是方案一,因为在UI开发中,有一条不成文规定:UI操作要尽量在 1/7 秒(约140毫秒)完成,这样用户就感受不到UI的“卡顿”情况。
这个时候有同学说了,我不会在OnButton函数中动手脚,我要修改的是DoBackup函数。因为备份就是写磁盘文件,我要将DoBackup分成两个,四个,甚至八个线程。两个线程的话,1号线程写文件前面的二分之一,2号线程写文件后的二分之一。四个,八个线程依次类推。那么原来需要10秒完成的操作,就会变成5秒,2.5秒,1.25秒。
那么这种方案是否可行呢?
什么是IO线程
上面提到,用多个线程往磁盘里写同一个文件,以此加快效率。而磁盘读写,属于IO操作,这里就需要理解一个概念:IO线程。
简单来说,IO线程就是一个内核消费者线程。生产者消费者线程大家能理解吧,是的,IO线程就是充当了一个消费者,当我们调用 fwrite, fread 之类的函数,其实就是在我们自己的线程里,抛了一个任务到磁盘IO线程里面,然后fwrite/fread函数进入事件等待(线程挂起)。当IO线程处理完后,会触发 fwrite/fread 的事件通知,fwrite/fread 所在线程被激活,线程恢复运行,fwrite/fread 函数返回结果。
所以大家要记住,文件操作函数,基本都是充当生产者的身份,往IO线程抛入任务,最后等待任务的完成。而一个磁盘只有一个IO线程,所以不管你有多少个线程写文件,最终真正消费这些任务的,只有一条线程。对一个磁盘的操作,不管你有多少个线程在抛入任务,最终这些任务都是被串行执行的,而不是并发或并行执行的,网卡IO亦是如此。而且多生产者往一个消费者抛任务,还会造成抢锁问题,加剧性能的下降。
临界区
临界区是windows系统常用的多线程同步原语,相当于 linux 的 mutex。函数的用法我就不介绍了,相当简单,我们说一些用法以外的知识点。
一般我们把锁分为用户态锁和内核态锁,比如临界区和原子操作就是用户态锁,而信号量,互斥量这些属于内核锁。内核锁顾名思义,就是会函数进入内核模式的锁,开销更大,效率也更慢。一般用户态锁相对来说效率都快一点。
值得一提的是临界区虽然称之为用户态锁,但它在用户态抢锁失败后,照样会陷入内核态的,等待其他线程释放锁后,当前线程才会被重新唤醒。
另外临界区还有一个 try_lock (尝试抢锁)的函数,这是一个很鸡肋的函数。因为优秀的程序员,代码逻辑和结果需要清晰明确,将一切可能发生的事情置于我们可控的范围内,try 这个名词就代码着你的逻辑不够明确。所以当你想用 try_lock 的时候,搞不好你的代码逻辑已经出问题了,很有可能隐藏着你看不到的代码风险在里面。
不使用try_lock是众多大佬的经验之谈,所以当你想用 try_lock 的时候,请思考用 lock 函数的替代方案。
不要乱用读写锁
与临界区相近的还有一个读写锁,读写锁其实是基于临界区的概念上封装的,你可以理解为读写锁是在临界区上加了一个读写状态队列实现的,也就是读写锁的代码比临界区更多,逻辑更复杂,函数效率更低。很多人容易被读写锁的名字误导,认为读的时候用读锁,写的时候用写锁,这样加锁的效率就会比临界区要高。其实并不是这样,由于读写锁的逻辑比临界区更复杂,所以抢锁的开销成本更高。
那什么情况下才使用读写锁呢?读写锁的应用场景十分苛刻,必须具备以下条件:
- 锁环境必须发生频繁的碰撞(抢锁行为),这个一个非常重要的先决条件,试想一下,你读写锁的函数调用是比临界区要耗时的,如果从来没有发生锁碰撞,你使用更加耗时的锁有什么意义?如果该条件不成立,则不应该使用读写锁,接下来下面的条件也是毫无意义的。
- 写少读多这个场景不必解释了,另外读和写一样多也是可以的,但是这种五五开的场景,必须是极度耗时的读写场景才适合使用读写锁。
- 锁内函数嵌套的层次不能过深,因为太深的话,后续的开发者可能不知道自己到底是处于读锁内还写锁内,如果在读锁内执行了写操作,就会引起脏数据甚至崩溃。
- 读写锁对象要经过封装才能使用,封装好后的锁必须能随时替换成临界区(可以通过宏开关控制)。这样做的原因有二。一是当怀疑读锁出现脏操作(写数据)时,替换成临界区能够方便观察异常。二是方便测试比较读写锁相比临界区是否在当前业务有性能的提升。
看完上面4个条件,是不是发现读写锁的应用十分麻烦?它不仅有性能问题,还有编码安全性问题,假设读写锁确实提升了我们的性能,那么我们也不得不额外考虑:为了这点性能的提升,舍弃编码的安全性,真的值得吗?
所以没啥事真的不要乱用读写锁。
并行;并发;串行
并行是指多个CPU同时执行多个任务,也可以说多个机器同时执行多个任务。在某一瞬间,这些多个任务都是出于执行状态的。
并发指的是单个线程执行多个任务,另外单个CPU在执行多个线程也属于并发。在某一瞬间,这些多个任务,其实只有一个是处于执行状态的。由于每次只执行任务的其中一部分流程,所以并行看上去好像多个任务在同时执行一样。
串行指的是,在同一时间内,只有一个任务在执行且直至该任务完成,如果任务有多个,必须执行完上一个,才能执行下一个。
如何学好多线程
这个是大家最关心的问题了,记得有一位大师说过,学好多线程的方法就是不要使用多线程!哈哈,听起来有点绕,其实这句话的本质意思是,单线程编程比多线程更难更复杂,当你能够熟练掌握单线程的精髓,多线程对你来说根本小菜一碟。
就拿上面的介绍响应知识点的伪代码来说。我们创建了一条新线程,很轻松就解决了UI卡顿问题,我相信一个毕业生也能轻松实现这个解决方案。但如果我们禁止创建新的线程呢?备份业务跟UI业务都必须在主线程中完成,你将如何做到备份操作不会卡顿UI?
现在你感受到单线程的编程难度了吧,要知道那个单核CPU的年代,又何尝不是一个执行流在处理千千万万个任务?换而言之,你想知道一个人是否真正理解多线程的用途,可以考察他将如何利用单线程完成多任务的。
想学好单线程编程,大家可以看看one loop per thread 和 reactor 的编程模型,体会并发模型中分时操作与分批操作的思想,对提升理解多线程编程有很大帮助。