别再被多线程搞晕了!一篇文章轻松搞懂 Linux 多线程同步!
前言
大家有没有遇到过,代码跑着跑着,线程突然抢资源抢疯了?其实,这都是“多线程同步”在作怪。多线程同步是个老生常谈的话题,可每次真正要处理时还是让人头疼。这篇文章,带你从头到尾掌握 Linux 的多线程同步,把概念讲成大白话,让你看了不再迷糊,还能拿出来装一装逼!不管是“锁”、“信号量”,还是“条件变量”,我们都一网打尽,赶紧点赞收藏,一文搞懂!
一、什么是线程同步?——“排队来操作,按规矩走”
线程同步的核心,就是控制多个线程的访问顺序,让它们在访问共享资源时有序、稳定。你可以把它想象成大家排队进电影院,每个线程都是观众,排好队才能有序进场。如果大家一拥而上,不仅容易出事,还谁也看不成电影。
简单来说,线程同步就是一个“排队工具”,让线程们按顺序、按规则去操作资源,避免混乱、出错。
二、 为什么需要多线程同步?——不想大家打架就得“排好队”
简单来说,多线程同步就是为了控制多个线程之间的访问顺序,保证数据的一致性,防止线程“打架”。比如你有多个线程在“抢”同一个变量,它们随时会互相影响,最终导致程序结果错得一塌糊涂,甚至程序崩溃。这时候就像几个朋友围在一桌,大家都想夹最后一块肉,结果谁也夹不到,甚至还打起来了!在计算机中,这个场景会导致资源冲突或者死锁。
三、线程同步的常见问题?
为什么多线程容易“打架”?因为线程是独立的执行单元,它们的执行顺序不确定。几个常见的问题:
竞争条件: 多个线程同时抢着用同一个资源,结果数据出错、搞乱了。
死锁: 线程互相等待彼此的资源,谁也不让谁,最后都卡在那儿不动了。
活锁: 线程为了避免冲突,不停地让来让去,结果谁也没法继续工作,任务一直停滞着。
所以,为了保证程序的正确性、数据一致性,Linux 提供了各种同步工具。可以理解为“排队工具”,让线程一个一个地来,用完再走,大家和平共处。
四、同步工具集锦:全家福
在 Linux 中,常用的同步工具主要有七类:
互斥锁(Mutex):一人一次,谁拿到谁操作,别抢!
条件变量(Condition Variable):有人负责通知,其他人等信号,一喊开工就一哄而上。
信号量(Semaphore):有限名额,控制同时访问资源的线程数量,适合多线程限流。
读写锁(Reader-Writer Lock):有读有写,读可以多人一起看,写得自己来。
自旋锁(Spin Lock):不停地检查锁,忙等。适合短时间锁定场景。
屏障(Barrier):所有线程到这儿集合,等到齐了一起开始下一步。
原子操作(Atomic Operations):小数据更新直接操作,不加锁,速度快,适合简单计数和标志位更新。
这些工具看起来好像有点复杂,但咱们一个一个来,保你一学就懂!
五、互斥锁(Mutex):谁拿到,谁先操作
互斥锁是多线程同步的基础。顾名思义,互斥锁(mutex)是一种独占机制,即一次只允许一个线程访问共享资源。要理解互斥锁的作用,可以想象一下“厕所上锁”的场景:假设家里有一个卫生间,进门时必须锁上,完事出来再开锁,以防别人误闯。
常见接口:
在 POSIX 线程库中,互斥锁通过 pthread_mutex_t
类型实现,提供了以下常见接口:
pthread_mutex_init(&mutex, nullptr)
:初始化互斥锁pthread_mutex_lock(&mutex)
:加锁,若已被其他线程锁定,则阻塞等待pthread_mutex_trylock(&mutex)
:尝试加锁,若锁已被占用,则立即返回错误而不阻塞pthread_mutex_unlock(&mutex)
:解锁,释放互斥锁,允许其他线程加锁pthread_mutex_destroy(&mutex)
:销毁互斥锁,释放相关资源
简单代码示例:
这段代码展示了如何使用互斥锁(mutex)来确保多个线程对共享变量 counter
的安全访问。
代码解释:
increment 函数:每个线程调用此函数,对 counter
变量进行加 1 操作。为了防止多个线程同时修改 counter
,使用了互斥锁:
pthread_mutex_lock(&mutex)
:加锁,确保只有一个线程可以修改counter
counter++
:增加counter
的值pthread_mutex_unlock(&mutex)
:解锁,允许其他线程访问
主函数 main:
pthread_mutex_init(&mutex, nullptr)
:初始化互斥锁创建两个线程 t1 和 t2,它们都执行
increment
函数pthread_join
等待 t1 和 t2 结束打印
counter
的最终值pthread_mutex_destroy(&mutex)
:销毁互斥锁,释放资源
通过互斥锁的加锁和解锁,代码确保了两个线程不会同时修改 counter
,从而保证数据安全。
优缺点
优点:
简单高效:互斥锁的加锁和解锁操作非常简单,运行效率高,适合需要短时间锁定资源的场合。
数据安全:互斥锁可以保证同一时刻只有一个线程访问共享资源,避免数据冲突,保证数据的一致性。
防止资源争抢:互斥锁确保资源不被多个线程同时访问,从而避免竞争带来的数据错误或程序崩溃。
缺点:
阻塞其他线程:一旦资源被锁定,其他线程只能等待,这可能导致系统效率降低,尤其是锁定时间较长时。
存在死锁风险:如果两个线程互相等待对方释放锁,就可能导致死锁。因此设计锁的使用顺序时需要格外小心。
不适合长时间锁定:互斥锁适合短期操作,锁定时间过长会影响程序的并发性,因为其他线程在等待锁时会被阻塞,降低系统性能。
应用场景:
互斥锁适合那些需要独占资源访问的情况,比如多个线程同时需要修改同一个变量、更新配置文件、写文件等操作。互斥锁确保这些操作不会被打断,资源在操作时“锁”住,保证访问的有序和安全性。
六、条件变量(Condition Variable): 有信号才行动
条件变量有点像“等通知”。一个线程负责等信号,另一个线程发出信号。比如生产者和消费者,消费者要等到有货了才能继续;生产者一旦备好了货,就发个信号给消费者,“来吧,过来取,货到齐了!”
常见接口:
在 POSIX 线程库中,条件变量通过 pthread_cond_t
类型实现,配合互斥锁使用,常见接口包括以下几种:
pthread_cond_init(&cond, nullptr)
:初始化条件变量。pthread_cond_wait(&cond, &mutex)
:等待条件变量。需要持有互斥锁,当条件不满足时自动释放锁并进入等待状态,直到接收到信号或被唤醒。pthread_cond_signal(&cond)
:发送信号,唤醒一个正在等待的线程。适用于通知单个等待线程的情况。pthread_cond_broadcast(&cond)
:广播信号,唤醒所有正在等待的线程。pthread_cond_destroy(&cond)
:销毁条件变量,释放相关资源。
简单代码示例:
这段代码展示了如何使用 条件变量(Condition Variable) 和 互斥锁(Mutex) 来协调两个线程之间的同步。代码中有两个线程,一个线程在等待信号,另一个线程发送信号。
代码解释:
waitForSignal
函数:等待信号的线程,加锁后检查ready
的状态。如果ready
为false
,线程会调用pthread_cond_wait
进入等待状态,直到收到sendSignal
线程的信号才继续执行。sendSignal
函数:发送信号的线程,先加锁,将ready
设为true
,然后调用pthread_cond_signal
通知等待线程可以继续。最后解锁,让waitForSignal
线程继续执行。主函数
main
: 初始化互斥锁和条件变量,创建两个线程t1
和t2
,分别执行等待和发送信号的任务,最后等待线程完成并销毁互斥锁和条件变量。
优缺点:
优点:
减少忙等:使用条件变量可以让线程进入等待状态,不消耗 CPU 资源,等待到达信号再继续执行,提升效率。
多线程协作更有序:条件变量使线程之间的配合更有序,避免资源的无效争抢。
支持多线程唤醒:条件变量的广播功能可以一次唤醒多个线程,非常适合需要同步的多线程场景。
缺点:
需要互斥锁配合:条件变量不能单独使用,必须与互斥锁一起使用,增加了编写的复杂度。
可能出现虚假唤醒:
pthread_cond_wait
可能会出现“虚假唤醒”情况,因此需要在循环中反复检查条件是否满足。编程复杂度增加:对于新手来说,条件变量与互斥锁的搭配使用会增加多线程编程的难度。
应用场景:
条件变量适用于生产者-消费者模型等场景,非常适合一个线程需要等待另一个线程完成某些操作的情况,比如等待任务完成、资源释放、数据处理等。通过条件变量,一个线程可以在等待条件达成时自动暂停,等收到信号后再继续执行。
七、信号量(Semaphore):谁来谁得,有限名额
信号量就像门口的限流器。允许一定数量的线程同时进入“临界区”(共享资源区),超过这个数量的线程就得在门口等着。比如限量版奶茶店,一次只能进五个人,想喝就得排队!
常见接口:
在 POSIX 线程库中,信号量通过 sem_t
类型实现,接口主要包括:
sem_init(&semaphore, 0, count)
:初始化信号量,count
是信号量初始值,表示同时允许进入的线程数量。sem_wait(&semaphore)
:请求资源。当信号量大于零时,减一并进入临界区;若信号量为零,则线程阻塞,直到其他线程释放资源。sem_post(&semaphore)
:释放资源,增加信号量值,允许其他等待的线程继续。sem_destroy(&semaphore)
:销毁信号量,释放资源。
简单代码示例:
下面的代码展示了如何使用信号量来控制多个线程对资源的访问权限。在这个例子中,信号量初始值为 1,确保同一时间只有一个线程能进入临界区。
代码解释:
sem_wait(&semaphore);
:请求访问资源,信号量减一。如果信号量为零,线程将等待。sem_post(&semaphore);
:释放资源,信号量加一,让其他等待的线程可以进入。
主函数中,两个线程 t1
和 t2
会分别调用 accessResource
。信号量初始值设为 1
,保证同一时刻只有一个线程访问资源,避免冲突。
优缺点
优点:
控制并发量:信号量允许多个线程同时进入,特别适合一些允许并行读的场景,比如文件读写或数据库连接池。
灵活性强:信号量不仅支持单线程进入,还支持多线程进入。
缺点:
不易编程和调试:由于信号量的计数器机制,容易导致逻辑混乱,编程复杂且调试较难。
不能识别优先级:信号量没有内置的优先级队列,某些等待时间长的线程可能会“饿死”。
应用场景:
限流:例如数据库连接池中限制同时连接数,通过信号量控制最大连接数。
读写分离:读操作允许多个线程同时进行,而写操作需要独占访问。
共享资源管理:如资源池、任务队列等,有固定容量的资源池中允许多个线程访问,但超过容量则需等待。
信号量在限制并发时非常实用,能够灵活控制线程数量,特别适合一些读写分离或限流场景,是多线程同步中的“好帮手”。
八、读写锁(Reader-Writer Lock):读可以一起,写得单独
读写锁的作用顾名思义,就是让“读”操作更轻松。在多线程场景中,多个线程可以同时读取资源(共享查看),但写操作必须独占,确保不会在读取时被其他线程修改内容。这就像图书馆的书,大家可以一起看,但如果有人要修改书的内容,就得把书借走,防止其他人读到一半内容突然变了。
常见接口 :
pthread_rwlock_init(&rwlock, nullptr)
:初始化读写锁。在使用读写锁之前必须初始化,可以选择设置锁的属性(用nullptr
表示默认属性)。pthread_rwlock_rdlock(&rwlock)
:加读锁。多个线程可以同时持有读锁,但如果有线程持有写锁,调用线程会被阻塞,直到写锁释放。pthread_rwlock_wrlock(&rwlock)
:加写锁。加写锁时,线程需独占读写锁。持有写锁期间,所有其他的读锁或写锁请求都会被阻塞,直到写锁被释放。pthread_rwlock_unlock(&rwlock)
:解锁。无论是读锁还是写锁,都可以使用该接口解锁。若当前持有读锁,则释放一个读锁;若持有写锁,则释放写锁,允许其他线程加锁。pthread_rwlock_destroy(&rwlock)
:销毁读写锁。在不再需要使用读写锁时销毁它,释放相关的资源。
简单代码示例:
这段代码展示了读写锁(rwlock)的基本用法,目的是让多个线程同时访问共享变量 counter
,并确保读取和写入操作的安全性。
代码解释:
readCounter
函数:获取读锁pthread_rwlock_rdlock
,读取counter
的值并打印,然后释放读锁。多个线程可以同时获取读锁,允许并发读取。writeCounter
函数:获取写锁pthread_rwlock_wrlock
,增加counter
的值,然后释放写锁。写锁是独占的,同一时间只有一个线程可以写入counter
。main
函数:创建了三个线程t1
、t2
和t3
,两个线程进行读取操作(readCounter
),一个线程进行写入操作(writeCounter
)。读写锁rwlock
确保了读取和写入时的线程安全。
优缺点
优点:
高效的读操作:多个线程可以同时读取资源,不会互相阻塞,避免了因互斥锁导致的效率低下。
写操作安全:写操作独占锁,确保数据不会因为读写交叉而出错。
缺点:
可能导致“写饥饿”:如果一直有线程在读取,写线程可能一直无法获取锁,导致写操作被延迟。
不适合频繁写的场景:在写操作多的情况下,读写锁的优势不明显,反而因为锁的开销影响性能。
应用场景:
日志和配置读取: 日志内容可以被多个线程同时读取,但在写日志或更新配置时需要独占。
缓存系统:例如计数器等共享资源,多线程环境中读多写少的缓存操作特别适合读写锁。
统计数据系统: 数据读取频繁而写入较少的统计系统中,读写锁能提供更高的读取效率。
九、自旋锁(Spinlock):等不到就原地打转
自旋锁是种“忙等”锁,不获取到锁,它就原地打转,一直“自旋”等待。自旋锁适合短时间加锁的场景,时间一长就耗 CPU 了,所以常用于等待时间极短的资源。因此,自旋锁经常用于等待时间非常短的资源访问场景。
常见接口 :
pthread_spin_init(pthread_spinlock_t* lock, int pshared)
初始化自旋锁,参数
pshared
指定自旋锁是否在进程间共享(0 表示仅在进程内使用)。如果成功返回 0,否则返回错误代码。
pthread_spin_lock(pthread_spinlock_t* lock)
加锁操作,尝试获取自旋锁。如果锁已经被占用,当前线程会一直循环等待,直到获取锁。
pthread_spin_unlock(pthread_spinlock_t* lock)
解锁操作,释放自旋锁,让其他线程可以继续尝试获取锁。
pthread_spin_destroy(pthread_spinlock_t* lock)
销毁自旋锁,释放资源。调用此函数后不能再使用该锁,除非重新初始化。
简单代码示例:
下面的代码展示了如何使用自旋锁在两个线程间进行资源访问控制,确保 counter
的安全递增。
代码解释:
increment
函数:每个线程调用此函数,对counter
进行加 1 操作。为了确保线程安全,使用了自旋锁spinlock
:
pthread_spin_lock(&spinlock)
:加锁,使当前线程独占访问counter
。counter++
:增加counter
的值。pthread_spin_unlock(&spinlock)
:解锁,让其他线程可以访问counter
。
主函数 main
:
pthread_create(&t1, nullptr, increment, nullptr)
和pthread_create(&t2, nullptr, increment, nullptr)
:创建两个线程t1
和t2
,分别执行increment
函数。pthread_join(t1, nullptr)
和pthread_join(t2, nullptr)
:等待t1
和t2
执行完毕。
通过自旋锁,这段代码确保了两个线程不会同时修改counter
,保证了数据安全。
优缺点
优点:
减少上下文切换:自旋锁不会让线程进入“阻塞等待”,而是让线程“忙等”来获取锁。这样避免了线程进入“睡眠-唤醒”的过程(即“上下文切换”),使得等待过程更快。
适合短时间锁定:自旋锁适合那些等待时间极短的情况,因为在这种情况下,等待时间和“忙等”的成本低于切换上下文的开销。
缺点:
在高竞争环境下性能下降:如果多个线程同时竞争同一个锁,自旋锁的“忙等”会导致大量线程占用 CPU,最终让 CPU 资源被浪费,导致性能下降。
不适合长时间锁定:如果持有锁的时间较长,线程会在等待期间不断占用 CPU,造成资源浪费。因此,自旋锁只适合持锁时间非常短的场景。
应用场景:
适合短时、高频锁的情况:在多核 CPU 上,自旋锁非常适合那些“锁定时间极短但加锁频繁”的情况,比如快速更新某个标志位、计数器等。这种操作速度快、锁的持有时间短,因此用自旋锁可以减少阻塞带来的上下文切换开销。
十、屏障(Barrier):到齐了就开工
屏障的作用是让一组线程都到达某个集合点,然后再一起继续。可以把它看作一个“集合点”,每个线程到这儿后必须等一等,直到所有线程都到齐,然后才能一起“放行”。这在需要同步的多线程任务中特别有用,比如并行的数据处理:每一阶段的数据处理需要多个线程完成,各自到达指定点后,才能一起进入下一个阶段。
常见接口:
在 POSIX 线程库中,屏障通过 pthread_barrier_t
类型实现,常用接口包括以下几个:
pthread_barrier_destroy(&barrier)
:销毁屏障,释放资源,通常在程序结束时调用。pthread_barrier_init(pthread_barrier_t* barrier, const pthread_barrierattr_t* attr, unsigned count)
:初始化屏障,count
参数指定屏障需要等待的线程数量。到达count
个线程后,屏障会放行所有等待的线程。pthread_barrier_wait(pthread_barrier_t* barrier)
:线程调用此函数后进入等待状态,直到所有线程都调用了这个函数,屏障才会释放线程进入下一步操作。pthread_barrier_destroy(pthread_barrier_t* barrier)
:销毁屏障,释放相关资源。
简单代码示例:屏障同步
下面的代码展示了如何使用屏障让三个线程同步等待,等到三个线程都到达屏障点后再继续执行。这样可以确保每个线程都在同一个步骤上同步。
代码解释:
waitAtBarrier
函数:每个线程在此函数中执行,先打印“Thread waiting at barrier...”表示到达屏障,然后调用 pthread_barrier_wait(&barrier)
在屏障处等待,直到所有线程都到达,之后才继续执行并打印“Thread passed the barrier!”。
主函数 main
:
pthread_barrier_init(&barrier, nullptr, 3);
:初始化屏障,要求 3 个线程同步到达。创建了 3 个线程(
t1
,t2
,t3
),它们都调用waitAtBarrier
函数。pthread_join
等待所有线程完成。pthread_barrier_destroy(&barrier);
:销毁屏障,释放资源。
这段代码的效果是:3 个线程都会在屏障处等待,直到全部线程到达后再一起通过,确保同步执行。
优缺点
优点:
简化阶段性同步:屏障特别适合多线程任务中的分阶段同步,比如大规模数据分批处理,每批数据处理完,所有线程集齐后再进入下一阶段。
简单易用:在需要多个线程同步的场景中,屏障提供了一个简单的方案,避免了手动计数和锁的复杂性。
缺点:
不灵活:屏障初始化时需要指定同步的线程数,在运行中无法动态更改,这在一些线程数变化的场景中可能不够灵活。
资源浪费:屏障需要等待所有线程到齐才能继续,若某些线程执行速度慢,会导致其他线程在等待时浪费 CPU 资源。
容易形成死锁:如果有线程没有到达屏障点,其他线程会一直等待,可能导致整个系统的线程死锁。
应用场景:
屏障适用于需要同步阶段的场合,尤其是以下几种:
分步数据处理:在数据处理中,有些步骤需要所有线程同步完成后再进入下一步。
阶段性任务同步:对于一些分阶段的任务,每一步都需要多个线程协同完成,比如并行计算中的同步步骤。
多线程计算汇合:比如科学计算、数据聚合等任务,每个线程完成部分任务后需要在屏障点集合汇总。
十一、原子操作(Atomic Operations):小块更新,快准狠
原子操作是一种“小而快”的多线程操作。它直接对数据进行“独占式”的更新,操作不可分割,不需要加锁,因为它的操作是原子的:要么全做,要么全不做。适合用于快速更新小数据,比如计数、标志位等场景。在多线程环境中使用原子操作,可以避免加锁带来的性能开销,因此更新简单共享资源时,非常高效。
常见接口:
在 C++的标准库中,原子操作接口非常简单,常用的有以下几种:
std::atomic<T>
声明一个原子类型的变量
T
,常用于简单数据类型,如int
、bool
等。std::atomic<int> counter(0);
表示一个整型原子变量counter
,初始值为 0。
fetch_add()
和fetch_sub()
分别用于原子加和原子减操作,例如
counter.fetch_add(1);
会安全地加 1,同时返回旧值。
load()
和store()
load()
用于原子地读取变量值,store()
用于原子地存储值,确保数据的一致性。
简单代码示例:原子操作实现计数器
下面的代码展示了如何使用 std::atomic
来安全地对共享数据 counter
进行递增操作。此处无需加锁,原子操作自动确保了线程安全。
代码解释:
std::atomic<int> counter(0);
:使用原子类型std::atomic
声明计数器counter
。所有对counter
的操作都是线程安全的。counter++
:原子递增操作,无需加锁,在多线程环境下也能保证数据的一致性。
通过原子操作,我们避免了加锁带来的性能开销,代码简洁、高效,特别适合对小数据的频繁更新。
优缺点
优点:
无需加锁:原子操作是天然的线程安全操作,不需要额外的锁机制。
性能高:原子操作减少了锁开销,性能更高,特别适合小范围的更新操作。
代码简单:使用
std::atomic
可以直接更新共享数据,代码更简洁。
缺点:
只适合简单数据:原子操作适用于小数据的单个操作,无法用于复杂的数据结构或多步操作。
不支持复杂同步:原子操作仅适合简单的同步需求,比如计数、标志位等,无法处理复杂的并发控制。
可能影响可读性:如果不熟悉原子操作的语义,代码的可读性可能较低。
应用场景:
原子操作非常适合以下几种场合:
计数器:在多线程环境中,对计数器的增减操作非常高效,比如线程池中的任务计数。
标志位更新:更新多线程任务中的状态标志,比如任务是否完成、资源是否可用等。
快速计数统计:在需要频繁更新的场合,原子操作可以避免锁带来的性能开销,提高统计速度。
总结:
今天我们一起探讨了 Linux 中的多线程同步方式,从互斥锁到条件变量,再到信号量、读写锁以及自旋锁、还有屏障和原子操作,逐一解锁了每种同步方式的应用场景和优缺点。学会这些技巧后,写多线程程序就不再让人头疼了! 同步其实并不神秘,只要掌握好这些基础工具,你也能写出流畅又安全的多线程程序 。
如果觉得有帮助,别忘了点赞和分享,关注我,我们一起学更多有趣的编程知识!已经掌握了这些同步方式? 那恭喜你!如果还没完全弄明白,没关系,欢迎在评论区留言,我们一起讨论,确保你都能搞懂!
文章转载自:江小康
评论