作者:美团技术团队
链接:https://tech.meituan.com/2024/07/19/multi-threading-and-multi-thread-synchronization.html
来源:美团技术团队
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
进程的概念?
可执行程序在操作系统上的一次执行对应一个进程,进程是一个动态的概念:进程是执行中的程序。同一份可执行文件执行多次,会产生多个进程,这跟一个类可以创建多个实例一样。进程是资源分配的基本单位。
线程的概念?
线程是一个执行上下文,它包含诸多状态数据:每个线程有自己的执行流、调用栈、错误码、信号掩码、私有数据。Linux内核用任务(Task)表示一个执行流。操作系统中,被调度执行的最小单位是线程而非进程。进程是通过共享存储空间对用户呈现的逻辑概念,同一进程内的多个线程共享地址空间和文件描述符,共享地址空间意味着进程的代码(函数)区域、全局变量、堆、栈都被进程内的多线程共享。
进程和线程的关系?
Linus详细阐述了他对进程和线程关系的深刻洞见:
①把进程和线程区分为不同的实体是背着历史包袱的传统做法,没有必要做这样的区分,甚至这样的思考方式是一个主要错误
②进程和线程都是一回事
③Linux内核认为根本没有所谓的进程和线程的概念
④从实现来看,Linux下的线程目前是LWP实现,线程就是轻量级进程,所有的线程都当作进程来实现,因此线程和进程都是用task_struct来描述的。这一点通过/proc文件系统也能看出端倪,线程和进程拥有比较平等的地位。对于多线程来说,原本的进程称为主线程,它们在一起组成一个线程组。
协程的概念?
用户态的多执行流,上下文切换成本比线程更低。
什么是多线程?
一个进程内多个线程并发执行的情况就叫多线程,每个线程是一个独立的执行流,多线程是一种编程模型,它与处理器无关、跟设计有关。
为什么需要多线程?
并行计算,充分利用多核,提升整体吞吐,加快执行速度;后台任务处理,将后台线程和主线程分离,在特定场景它是不可或缺的。
什么是多线程同步?
同一进程内的多个线程会共享数据,对共享数据的并发访问会出现Race Condition,这个词的官方翻译是竞争条件。多线程同步是指协调多个线程对共享数据的访问,避免出现数据不一致的情况。协调各个事件的发生顺序,使多线程在某个点交汇并按预期步骤往前推进,比如某线程需要等另一个线程完成某项工作才能开展该线程的下一步工作。
多线程同步保护保护的是什么?
多线程程序里,我们要保护的是数据而非代码,虽然Java等语言里有临界代码、sync方法,但最终要保护的还是代码访问的数据。
如何保护?
串行化:
如果有一个线程正在访问某共享(临界)资源,那么在它结束访问之前,其他线程不能执行访问同一资源的代码(访问临界资源的代码叫临界代码),其他线程想要访问同一资源,则它必须等待,直到那个线程访问完成,它才能获得访问的机会。
原子操作和原子变量:
Linux操作系统和C/C++编程语言都提供了整型原子变量,原子变量的自增、自减等操作都是原子的,操作是原子性的,意味着它是一个不可细分的操作整体,如何保证原子性是实现层面的问题,应用程序员只需要从逻辑上理解原子性,并能恰当的使用它就行了。原子变量非常适用于计数、产生序列号这样的应用场景。
锁:
互斥锁:
互斥锁可以确保在同一时间,只能有一个线程对那个共享资源进行访问。
互斥锁有且只有2种状态:
已加锁(locked)状态
未加锁(unlocked)状态
互斥锁提供加锁和解锁两个接口:
加锁(acquire):当互斥锁处于未加锁状态时,则加锁成功(把锁设置为已加锁状态),并返回;当互斥锁处于已加锁状态时,那么试图对它加锁的线程会被阻塞,直到该互斥量被解锁。
解锁(release):通过把锁设置为未加锁状态释放锁,其他因为申请加锁而陷入等待的线程,将获得执行机会。如果有多个等待线程,只有一个会获得锁而继续执行。
读写锁:
读写锁跟互斥锁类似,也是申请锁的时候,如果不能得到满足则阻塞,但读写锁跟互斥锁也有不同,读写锁有3个状态:
已加读锁状态
已加写锁状态
未加锁状态
对应3个状态,读写锁有3个接口:加读锁,加写锁,解锁:
加读锁:如果读写锁处于已加写锁状态,则申请锁的线程阻塞;否则把锁设置为已加读锁状态并成功返回。
加写锁:如果读写锁处于未加锁状态,则把锁设置为已加写锁状态并成功返回;否则阻塞。
解锁:把锁设置为未加锁状态后返回。
读写锁提升了线程的并行度,可以提升吞吐。它可以让多个读线程同时读共享资源,而写线程访问共享资源的时候,其他线程不能执行,所以,读写锁适合对共享资源访问“读大于写”的场合。读写锁也叫“共享互斥锁”,多个读线程可以并发访问同一资源,这对应共享的概念,而写线程是互斥的,写线程访问资源的时候,其他线程无论读写,都不可以进入临界代码区。
自旋锁:
自旋锁(Spinlock)的接口跟互斥量差不多,但实现原理不同。线程在acquire自旋锁失败的时候,它不会主动让出CPU从而进入睡眠状态,而是会忙等,它会紧凑的执行测试和设置(Test-And-Set)指令,直到TAS成功,否则就一直占着CPU做TAS。
自旋锁对使用场景有一些期待,它期待acquire自旋锁成功后很快会release锁,线程运行临界区代码的时间很短,访问共享资源的逻辑简单,这样的话,别的acquire自旋锁的线程只需要忙等很短的时间就能获得自旋锁,从而避免被调度走陷入睡眠,它假设自旋的成本比调度的低,它不愿耗费时间在线程调度上(线程调度需要保存和恢复上下文需要耗费CPU)。Linux系统优化过后的mutex实现,在加锁的时候会先做有限次数的自旋,只有有限次自旋失败后,才会进入睡眠让出CPU,所以,实际使用中,它的性能也足够好。
死锁出现的两种情况
ABBA锁
自死锁
锁同步的问题
线程同步分为阻塞型同步和非阻塞型同步。
互斥量、信号、条件变量这些系统提供的机制都属于阻塞型同步,在争用资源的时候,会导致调用线程阻塞。
非阻塞型同步是指在无锁的情况下,通过某种算法和技术手段实现不用阻塞而同步。
锁是阻塞同步机制,阻塞同步机制的缺陷是可能挂起你的程序,如果持有锁的线程崩溃或者hang住,则锁永远得不到释放,而其他线程则将陷入无限等待;另外,它也可能导致优先级倒转等问题。所以,我们需要lock-free这类非阻塞的同步机制。
Lock-Free的实现
CAS
Lock-Free同步主要依靠CPU提供的read-modify-write原语,著名的“比较和交换”CAS(Compare And Swap)在X86机器上是通过cmpxchg系列指令实现的原子操作。
CAS接受3个参数:
内存地址
期望值,通常传第一个参数所指内存地址中的旧值
新值
逻辑描述:CAS比较内存地址中的值和期望值,如果不相同就返回失败,如果相同就将新值写入内存并返回成功。
非阻塞算法
非阻塞算法保证竞争共享资源的线程,不会因为互斥而让它们的执行无限期暂停
程序的顺序执行
什么是内存屏障?
内存屏障(Memory Barrier),也称内存栅栏、屏障指令等,是一类同步屏障指令,是CPU或编译器在对内存随机访问的操作中的一个同步点,同步点之前的所有读写操作都执行后,才可以开始执行此点之后的操作。语义上,内存屏障之前的所有写操作都要写入内存;内存屏障之后的读操作都可以获得同步屏障之前的写操作的结果。内存屏障,其实就是提供一种机制,确保代码里顺序写下的多行,会按照书写的顺序,被存入内存,主要是解决Store Buffer引入导致的写入内存间隙的问题。
总结
pthread接口提供的几种同步原语:
非锁但是也能实现线程安全或者部分线程安全的常见做法: