多线程编程是现代软件开发中的一项关键技术,在多线程编程中,开发者可以将复杂的任务分解为多个独立的线程,使其并行执行,从而充分利用多核处理器的优势。然而,多线程编程也带来了挑战,例如线程同步、死锁和竞态条件等问题。本篇文章将深入探讨多线程编程的基本概念(原子操作、CAS、Lock-free、内存屏障、伪共享、乱序执行等)、常见模式和最佳实践。通过具体的代码示例,希望能够帮助大家掌握多线程编程的核心技术,并在实际开发中应用这些知识,提升软件的性能和稳定性。
1 多线程
1.1 线程的概念
十多年前,主流观点主张在可能的情况下优先选择多进程而非多线程。如今,多线程编程已经成为编程领域的事实标准。多线程技术在很大程度上改善了程序的性能和响应能力,使其能够更加高效地利用系统资源,这不仅归功于多核处理器的普及和软硬件技术的进步,还归功于开发者对多线程编程的深入理解和技术创新。
1.1.1 逻辑线程 vs 硬件线程
逻辑线程:程序上的线程是一个逻辑上的概念,也叫任务、软线程、逻辑线程。
硬件线程:与逻辑线程对应的是硬件线程,这是逻辑线程被执行的物质基础。
芯片设计领域,一个硬件线程通常指为执行指令序列而配套的硬件单元,一个CPU可能有多个核心,然后核心还可能支持超线程,1个核心的2个超线程复用一些硬件。从软件的视角来看,无须区分是真正的Core和超出来的VCore,基本上可以认为是2个独立的执行单元,每个执行单元是一个逻辑CPU,从软件的视角看CPU只需关注逻辑CPU。一个软件线程由哪个CPU/核心去执行,以及何时执行,不归应用程序员管,它由操作系统决定,操作系统中的调度系统负责此项工作。
1.3 程序、进程、线程、协程
进程和线程是操作系统领域的两个重要概念,两者既有区别又有联系。
1.3.1 可执行程序
C/C++源文件经过编译器(编译+链接)处理后,会产生可执行程序文件,不同系统有不同格式,比如Linux系统的ELF格式、Windows系统的EXE格式,可执行程序文件是一个静态的概念。
1.3.2 进程是什么
可执行程序在操作系统上的一次执行对应一个进程,进程是一个动态的概念:进程是执行中的程序。同一份可执行文件执行多次,会产生多个进程,这跟一个类可以创建多个实例一样。进程是资源分配的基本单位。
1.3.3 线程是什么
一个进程内的多个线程代表着多个执行流,这些线程以并发模式独立执行。操作系统中,被调度执行的最小单位是线程而非进程。进程是通过共享存储空间对用户呈现的逻辑概念,同一进程内的多个线程共享地址空间和文件描述符,共享地址空间意味着进程的代码(函数)区域、全局变量、堆、栈都被进程内的多线程共享。
1.3.4 进程和线程的关系
先看看linus的论述,在1996年的一封邮件里,Linus详细阐述了他对进程和线程关系的深刻洞见,他在邮件里写道:
- 把进程和线程区分为不同的实体是背着历史包袱的传统做法,没有必要做这样的区分,甚至这样的思考方式是一个主要错误。
- 进程和线程都是一回事:一个执行上下文(context of execution),简称为COE,其状态包括:
- CPU状态(寄存器等)
- MMU状态(页映射)
- 权限状态(uid、gid等)
- 各种通信状态(打开的文件、信号处理器等)
- 传统观念认为:进程和线程的主要区别是线程有CPU状态(可能还包括其他最小必要状态),而其他上下文来自进程;然而,这种区分法并不正确,这是一种愚蠢的自我设限。
- Linux内核认为根本没有所谓的进程和线程的概念,只有COE(Linux称之为任务),不同的COE可以相互共享一些状态,通过此类共享向上构建起进程和线程的概念。
- 从实现来看,Linux下的线程目前是LWP实现,线程就是轻量级进程,所有的线程都当作进程来实现,因此线程和进程都是用task_struct来描述的。这一点通过/proc文件系统也能看出端倪,线程和进程拥有比较平等的地位。对于多线程来说,原本的进程称为主线程,它们在一起组成一个线程组。
- 简言之,内核不要基于进程/线程的概念做设计,而应该围绕COE的思考方式去做设计,然后,通过暴露有限的接口给用户去满足pthreads库的要求。
1.3.5 协程
用户态的多执行流,上下文切换成本比线程更低,微信用协程改造后台系统后,获得了更大吞吐能力和更高稳定性。如今,协程库也进了C++ 20新标准。
1.4 为什么需要多线程
1.4.1 什么是多线程
一个进程内多个线程并发执行的情况就叫多线程,每个线程是一个独立的执行流,多线程是一种编程模型,它与处理器无关、跟设计有关。
需要多线程的原因包括:
- 并行计算:充分利用多核,提升整体吞吐,加快执行速度。
- 后台任务处理:将后台线程和主线程分离,在特定场景它是不可或缺的,如:响应式用户界面、实时系统等。
我们用2个例子作说明。
1.4.2 通过多线程并发提升处理能力
1.4.3 通过多线程改变程序编写方式
1.5 线程相关概念
1.5.1 时间分片
CPU先执行线程A一段时间,然后再执行线程B一段时间,然后再执行线程A一段时间,CPU时间被切分成短的时间片、分给不同线程执行的策略就是CPU时间分片。时间分片是对调度策略的一个极度简化,实际上操作系统的调度策略非常精细,要比简单的时间分片复杂的多。如果一秒钟被分成大量的非常短的时间片,比如100个10毫秒的时间片,10毫秒对人的感官而言太短了,以致于用户觉察不到延迟,仿佛计算机被该用户的任务所独占(实际上并不是),操作系统通过进程的抽象获得了这种任务独占CPU的效果(另一个抽象是进程通过虚拟内存独占存储)。
1.5.2 上下文切换
把当前正在CPU上运行的任务迁走,并挑选一个新任务到CPU上执行的过程叫调度,任务调度的过程会发生上下文切换(context swap),即保存当前CPU上正在运行的线程状态,并恢复将要被执行的线程的状态,这项工作由操作系统完成,需要占用CPU时间(sys time)。
1.5.3 线程安全函数与可重入
一个进程可以有多个线程在同时运行,这些线程可能同时执行一个函数,如果多线程并发执行的结果和单线程依次执行的结果是一样的,那么就是线程安全的,反之就不是线程安全的。
不访问共享数据,共享数据包括全局变量、static local变量、类成员变量,只操作参数、无副作用的函数是线程安全函数,线程安全函数可多线程重入。每个线程有独立的栈,而函数参数保存在寄存器或栈上,局部变量在栈上,所以只操作参数和局部变量的函数被多线程并发调用不存在数据竞争。
1.5.4 线程私有数据
因为全局变量(包括模块内的static变量)是进程内的所有线程共享的,但有时应用程序设计中需要提供线程私有的全局变量,这个变量仅在函数被执行的线程中有效,但却可以跨多个函数被访问。
比如在程序里可能需要每个线程维护一个链表,而会使用相同的函数来操作这个链表,最简单的方法就是使用同名而不同变量地址的线程相关数据结构。这样的数据结构可以由Posix线程库维护,成为线程私有数据 (Thread-specific Data,或称为 TSD)。
Posix有线程私有数据相关接口,而C/C++等语言提供thread_local关键字,在语言层面直接提供支持。
1.5.5 阻塞和非阻塞
一个线程对应一个执行流,正常情况下,指令序列会被依次执行,计算逻辑会往前推进。但如果因为某种原因,一个线程的执行逻辑不能继续往前走,那么我们就说线程被阻塞住了。就像下班回家,但走到家门口发现没带钥匙,只能在门口徘徊,任由时间流逝,而不能进入房间。
线程阻塞的原因有很多种,比如:
- 线程因为acquire某个锁而被操作系统挂起,如果acquire睡眠锁失败,线程会让出CPU,操作系统会调度另一个可运行线程到该CPU上执行,被调度走的线程会被加入等待队列,进入睡眠状态。
- 线程调用了某个阻塞系统调用而等待,比如从没有数据到来的套接字上读数据,从空的消息队列里读消息。
- 线程在循环里紧凑的执行测试&设置指令并一直没有成功,虽然线程还在CPU上执行,但它只是忙等(相当于白白浪费CPU),后面的指令没法执行,逻辑同样无法推进。
如果某个系统调用或者编程接口有可能导致线程阻塞,那么便被称之为阻塞系统调用;与之对应的是非阻塞调用,调用非阻塞的函数不会陷入阻塞,如果请求的资源不能得到满足,它会立即返回并通过返回值或错误码报告原因,调用的地方可以选择重试或者返回。
2 多线程同步
前面讲了多线程相关的基础知识,现在进入第二个话题,多线程同步。
2.1 什么是多线程同步
同一进程内的多个线程会共享数据,对共享数据的并发访问会出现Race Condition,这个词的官方翻译是竞争条件,但condition翻译成条件令人困惑,特别是对初学者而言,它不够清晰明了,翻译软件显示condition有状况、状态的含义,可能翻译成竞争状况更直白。
多线程同步是指:
- 协调多个线程对共享数据的访问,避免出现数据不一致的情况。
- 协调各个事件的发生顺序,使多线程在某个点交汇并按预期步骤往前推进,比如某线程需要等另一个线程完成某项工作才能开展该线程的下一步工作。
要掌握多线程同步,需先理解为什么需要多线程同步、哪些情况需要同步。
2.2 为什么需要同步
理解为什么要同步(Why)是多线程编程的关键,它甚至比掌握多线程同步机制(How)本身更加重要。识别什么地方需要同步是编写多线程程序的难点,只有准确识别需要保护的数据、需要同步的点,再配合系统或语言提供的合适的同步机制,才能编写安全高效的多线程程序。
2.3 保护什么
多线程程序里,我们要保护的是数据而非代码,虽然Java等语言里有临界代码、sync方法,但最终要保护的还是代码访问的数据。
2.4 串行化
如果有一个线程正在访问某共享(临界)资源,那么在它结束访问之前,其他线程不能执行访问同一资源的代码(访问临界资源的代码叫临界代码),其他线程想要访问同一资源,则它必须等待,直到那个线程访问完成,它才能获得访问的机会,现实中有很多这样的例子。比如高速公路上的汽车过检查站,假设检查站只有一个车道,则无论高速路上有多少车道,过检查站的时候只能一辆车接着一辆车,从单一车道鱼贯而入。
对多线程访问共享资源施加此种约束就叫串行化。
2.5 原子操作和原子变量
针对前面的两个线程对同一整型变量自增的问题,如果“load、update、store”这3个步骤是不可分割的整体,即自增操作++x满足原子性,上面的程序便不会有问题。
因为这样的话,2个线程并发执行++x,只会有2个结果:
- 线程a ++x,然后线程b ++x,结果是2。
- 线程b ++x,然后线程a ++x,结果是2。
除此之外,不会出现第三种情况,线程a、b孰先孰后,取决于线程调度,但不影响最终结果。
Linux操作系统和C/C++编程语言都提供了整型原子变量,原子变量的自增、自减等操作都是原子的,操作是原子性的,意味着它是一个不可细分的操作整体,原子变量的用户观察它,只能看到未完成和已完成2种状态,看不到半完成状态。
如何保证原子性是实现层面的问题,应用程序员只需要从逻辑上理解原子性,并能恰当的使用它就行了。原子变量非常适用于计数、产生序列号这样的应用场景。
2.6 锁
前面举了很多例子,阐述多线程不加同步并发访问数据会引起什么问题,下面讲解用锁如何做同步。
2.6.1 互斥锁
针对线程1 write_msg() + 线程2 read_msg()的问题,如果能让线程1 write_msg()的过程中,线程2不能read_msg(),那就不会有问题。这个要求,其实就是要让多个线程互斥访问共享资源。
互斥锁就是能满足上述要求的同步机制,互斥是排他的意思,它可以确保在同一时间,只能有一个线程对那个共享资源进行访问。
互斥锁有且只有2种状态:
- 已加锁(locked)状态
- 未加锁(unlocked)状态
互斥锁提供加锁和解锁两个接口:
- 加锁(acquire):当互斥锁处于未加锁状态时,则加锁成功(把锁设置为已加锁状态),并返回;当互斥锁处于已加锁状态时,那么试图对它加锁的线程会被阻塞,直到该互斥量被解锁。
- 解锁(release):通过把锁设置为未加锁状态释放锁,其他因为申请加锁而陷入等待的线程,将获得执行机会。如果有多个等待线程,只有一个会获得锁而继续执行。
我们为某个共享资源配置一个互斥锁,使用互斥锁做线程同步,那么所有线程对该资源的访问,都需要遵从“加锁、访问、解锁”的三步:
注意:在访问资源前申请锁访问后释放锁,是一个编程契约,通过遵守契约而获得数据一致性的保障,它并非一种硬性的限制,即如果别的线程遵从三步曲,而另一个线程不遵从这种约定,代码能通过编译且程序能运行,但结果可能是错的。
2.6.2 读写锁
读写锁跟互斥锁类似,也是申请锁的时候,如果不能得到满足则阻塞,但读写锁跟互斥锁也有不同,读写锁有3个状态:
- 已加读锁状态
- 已加写锁状态
- 未加锁状态
对应3个状态,读写锁有3个接口:加读锁,加写锁,解锁:
- 加读锁:如果读写锁处于已加写锁状态,则申请锁的线程阻塞;否则把锁设置为已加读锁状态并成功返回。
- 加写锁:如果读写锁处于未加锁状态,则把锁设置为已加写锁状态并成功返回;否则阻塞。
- 解锁:把锁设置为未加锁状态后返回。
读写锁提升了线程的并行度,可以提升吞吐。它可以让多个读线程同时读共享资源,而写线程访问共享资源的时候,其他线程不能执行,所以,读写锁适合对共享资源访问“读大于写”的场合。读写锁也叫“共享互斥锁”,多个读线程可以并发访问同一资源,这对应共享的概念,而写线程是互斥的,写线程访问资源的时候,其他线程无论读写,都不可以进入临界代码区。
考虑一个场景:如果有线程1、2、3共享资源x,读写锁rwlock保护资源,线程1读访问某资源,然后线程2以写的形式访问同一资源x,因为rwlock已经被加了读锁,所以线程2被阻塞,然后过了一段时间,线程3也读访问资源x,这时候线程3可以继续执行,因为读是共享的,然后线程1读访问完成,线程3继续访问,过了一段时间,在线程3访问完成前,线程1又申请读资源,那么它还是会获得访问权,但是写资源的线程2会一直被阻塞。
为了避免共享的读线程饿死写线程,通常读写锁的实现,会给写线程优先权,当然这处决于读写锁的实现,作为读写锁的使用方,理解它的语义和使用场景就够了。
2.6.3 自旋锁
自旋锁(Spinlock)的接口跟互斥量差不多,但实现原理不同。线程在acquire自旋锁失败的时候,它不会主动让出CPU从而进入睡眠状态,而是会忙等,它会紧凑的执行测试和设置(Test-And-Set)指令,直到TAS成功,否则就一直占着CPU做TAS。
自旋锁对使用场景有一些期待,它期待acquire自旋锁成功后很快会release锁,线程运行临界区代码的时间很短,访问共享资源的逻辑简单,这样的话,别的acquire自旋锁的线程只需要忙等很短的时间就能获得自旋锁,从而避免被调度走陷入睡眠,它假设自旋的成本比调度的低,它不愿耗费时间在线程调度上(线程调度需要保存和恢复上下文需要耗费CPU)。
内核态线程很容易满足这些条件,因为运行在内核态的中断处理函数里可以通过关闭调度,从而避免CPU被抢占,而且有些内核态线程调用的处理函数不能睡眠,只能使用自旋锁。
而运行在用户态的应用程序,则推荐使用互斥锁等睡眠锁。因为运行在用户态应用程序,虽然很容易满足临界区代码简短,但持有锁时间依然可能很长。在分时共享的多任务系统上、当用户态线程的时间配额耗尽,或者在支持抢占式的系统上、有更高优先级的任务就绪,那么持有自旋锁的线程就会被系统调度走,这样持有锁的过程就有可能很长,而忙等自旋锁的其他线程就会白白消耗CPU资源,这样的话,就跟自旋锁的理念相背。
Linux系统优化过后的mutex实现,在加锁的时候会先做有限次数的自旋,只有有限次自旋失败后,才会进入睡眠让出CPU,所以,实际使用中,它的性能也足够好。此外,自旋锁必须在多CPU或者多Core架构下,试想如果只有一个核,那么它执行自旋逻辑的时候,别的线程没有办法运行,也就没有机会释放锁。
2.6.4 锁的粒度
合理设置锁的粒度,粒度太大会降低性能,太小会增加代码编写复杂度。
2.6.5 锁的范围
锁的范围要尽量小,最小化持有锁的时间。
2.6.6 死锁
程序出现死锁有两种典型原因:
ABBA锁
假设程序中有2个资源X和Y,分别被锁A和B保护,线程1持有锁A后,想要访问资源Y,而访问资源Y之前需要申请锁B,而如果线程2正持有锁B,并想要访问资源X,为了访问资源X,所以线程2需要申请锁A。线程1和线程2分别持有锁A和B,并都希望申请对方持有的锁,因为线程申请对方持有的锁,得不到满足,所以便会陷入等待,也就没有机会释放自己持有的锁,对方执行流也就没有办法继续前进,导致相持不下,无限互等,进而死锁。
上述的情况似乎很明显,但如果代码量很大,有时候,这种死锁的逻辑不会这么浅显,它被复杂的调用逻辑所掩盖,但抽茧剥丝,最根本的逻辑就是上面描述的那样。这种情况叫ABBA锁,既某个线程持有A锁申请B锁,而另一个线程持有B锁申请A锁。这种情况可以通过try lock实现,尝试获取锁,如果不成功,则释放自己持有的锁,而不一根筋下去。另一种解法就是锁排序,对A/B两把锁的加锁操作,都遵从同样的顺序(比如先A后B),也能避免死锁。
自死锁
对于不支持重复加锁的锁,如果线程持有某个锁,而后又再次申请锁,因为该锁已经被自己持有,再次申请锁必然得不到满足,从而导致死锁。
2.9 程序序:Program Order
对单线程程序而言,代码会一行行顺序执行,就像我们编写的程序的顺序那样。
2.10 内存序:Memory Order
与程序序相对应的内存序,是指从某个角度观察到的对于内存的读和写所真正发生的顺序。内存操作顺序并不唯一,在一个包含core0和core1的CPU中,core0和core1有着各自的内存操作顺序,这两个内存操作顺序不一定相同。从包含多个Core的CPU的视角看到的全局内存操作顺序跟单core视角看到的内存操作顺序亦不同,而这种不同,对于有些程序逻辑而言,是不可接受的,例如:
程序序要求a = 1在b = 2之前执行,但内存操作顺序可能并非如此,对a赋值1并不确保发生在对b赋值2之前,这是因为:
- 如果编译器认为对b赋值没有依赖对a赋值,那它完全可能在编译期调整编译后的汇编指令顺序。
- 即使编译器不做调整,到了执行期,也有可能对b的赋值先于对a赋值执行。
虽然对一个Core而言,如上所述,这个Core观察到的内存操作顺序不一定符合程序序,但内存操作序和程序序必定产生相同的结果,无论在单Core上对a、b的赋值哪个先发生,结果上都是a被赋值为1、b被赋值为2,如果单核上乱序执行会影响结果,那编译器的指令重排和CPU乱序执行便不会发生,硬件会提供这项保证。
但多核系统,硬件不提供这样的保证,多线程程序中,每个线程所工作的Core观察到的不同内存操作序,以及这些顺序与全局内存序的差异,常常导致多线程同步失败,所以,需要有同步机制确保内存序与程序序的一致,内存屏障(Memory Barrier)的引入,就是为了解决这个问题,它让不同的Core之间,以及Core与全局内存序达成一致。
2.11 乱序执行:Out-of-order Execution
乱序执行会引起内存顺序跟程序顺序不同,乱序执行的原因是多方面的,比如编译器指令重排、超标量指令流水线、预测执行、Cache-Miss等。内存操作顺序无法精确匹配程序顺序,这有可能带来混乱,既然有副作用,那为什么还需要乱序执行呢?答案是为了性能。
我们先看看没有乱序执行之前,早期的有序处理器(In-order Processors)是怎么处理指令的?
- 指令获取,从代码节内存区域加载指令到I-Cache
- 译码
- 如果指令操作数可用(例如操作数位于寄存器中),则分发指令到对应功能模块中;如果操作数不可用,通常是需要从内存加载,则处理器会stall,一直等到它们就绪,直到数据被加载到Cache或拷贝进寄存器
- 指令被功能单元执行
- 功能单元将结果写回寄存器或内存位置
乱序处理器(Out-of-order Processors)又是怎么处理指令的呢?
- 指令获取,从代码节内存区域加载指令到I-Cache
- 译码
- 分发指令到指令队列
- 指令在指令队列中等待,一旦操作数就绪,指令就离开指令队列,那怕它之前的指令未被执行(乱序)
- 指令被派往功能单元并被执行
- 执行结果放入队列(Store Buffer),而不是直接写入Cache
- 只有更早请求执行的指令结果写入cache后,指令执行结果才写入Cache,通过对指令结果排序写入cache,使得执行看起来是有序的
指令乱序执行是结果,但原因并非只有CPU的乱序执行,而是由两种因素导致:
- 编译期:指令重排(编译器),编译器会为了性能而对指令重排,源码上先后的两行,被编译器编译后,可能调换指令顺序,但编译器会基于一套规则做指令重排,有明显依赖的指令不会被随意重排,指令重排不能破坏程序逻辑。
- 运行期:乱序执行(CPU),CPU的超标量流水线、以及预测执行、Cache-Miss等都有可能导致指令乱序执行,也就是说,后面的指令有可能先于前面的指令执行。
原文:基本功 | 一文讲清多线程和多线程同步 - 美团技术团队