线程同步
由于线程共享内存,访问共享数据(全局变量、堆内存)必须进行同步,以防止竞态条件(Race Conditions)导致数据不一致或程序崩溃。
子线程没有独立的地址空间,数据通常是共享的;如果同时访问数据会导致混乱,需要进行同步控制。
同步的核心思想是让某个线程在需要时获取资源(锁),其他线程在资源不可用时等待,直到资源可用再继续执行。
线程之间的同步通常通过两种锁来表达关系:
- 互斥锁(mutex):确保同一时间只有一个线程能够访问共享资源。
- 条件变量(condition variable):用于在某个条件成立时通知等待中的线程继续执行。
线程序列的执行需要保证执行的先后次序,常用的策略包括:
- 先获取锁、再检查条件、在必要时等待(阻塞);
- 条件满足时发出通知,唤醒等待的线程继续执行。
- 线程之间的协作与耦合要尽量降低,避免过度依赖全局状态,减少死锁风险。
- 资源管理要清晰,避免“资源占用-等待-再次占用”的循环,使并发程序保持高效。
理解futex
-
futex 是 Linux 提供的一个系统调用 (futex()) 和一种同步原语的构建思想。
-
它设计用于在用户空间和内核空间之间实现高效的同步(如互斥锁、读写锁、条件变量、信号量等)。
核心思想:
-
无竞争时走快速路径(用户空间): 在锁空闲(或条件满足)的情况下,同步操作(如加锁、解锁)完全在用户空间通过原子操作(如 cmpxchg)完成。这避免了昂贵的系统调用和内核态/用户态切换的开销。
-
竞争时走慢速路径(内核): 当操作无法立即完成(如锁已被占用、条件未满足),需要阻塞线程时,才会通过 futex() 系统调用进入内核。内核负责将线程挂起在 futex 关联的地址(uaddr)上,并在必要时(如解锁操作唤醒、条件满足)将其唤醒。
-
futex 本身只关注对一个用户空间地址 (uaddr) 的等待 (FUTEX_WAIT) 和唤醒 (FUTEX_WAKE) 操作。更复杂的同步原语(如互斥锁)是利用这个基础能力在用户空间构建的。
与线程的关系?
-
高效同步的基石: 现代 Linux POSIX 线程库(NPTL)使用 futex 作为其所有主要同步机制(互斥锁、条件变量、读写锁、屏障)的基础实现。
-
性能关键: 多线程程序中同步操作非常频繁。futex 的设计极大地降低了无竞争或低竞争情况下同步操作的开销,这是实现高性能多线程程序的关键。
-
API 透明性: 开发者仍然使用标准的 pthread_mutex_lock() 等 Pthreads API。futex 是这些 API 在 Linux 上高性能实现的底层秘密武器。
-
核心作用: 提供一种用户态优先的机制,使得在 Linux 上实现高性能的线程同步原语成为可能。它是 NPTL 高性能的关键因素之一。
简单来说:
- futex: Linux 实现高效同步的底层原语。pthread_mutex_lock/unlock(),
- 你用的锁和条件变量,在 Linux 里靠 futex 才能那么快。
互斥锁
-
(pthread_mutex_t)
-
最基本的同步原语。保证同一时刻只有一个线程能进入被保护的临界区 (Critical Section)。
-
实现: NPTL 利用 futex 实现高效的互斥锁。无竞争时,加锁/解锁操作完全在用户空间完成(原子操作)。当发生竞争需要阻塞线程时,才会陷入内核。
例:
- a++ 需要执行 3 个步骤:
- 取 a 的值
- 计算 a+1
- a+1 赋值给 a
- 某一时刻,若全局变量 a 的值为 5,线程 A 和线程 B 中都要执行 a++,若线程 A 在执行 a+1 赋值给 a 之前,这时线程 B 执行了 a++,线程 B 取到的 a 的值仍是 5,这时,线程 A 和 B 赋给 a 的值都是 6。值为 5 的变量 a 经过两次 a++ 结果却是 6,显然出现了错误。
- 多线程竞争操作共享变量的这段代码也会出错,俗称临界区。多个进程同时操作临界区会产生错误,所以这段代码应该互斥,当一个线程执行临界区时,应该阻止其他线程进入临界区。
- 为避免线程更进一步共享变量时出现问题,可以使用互斥量(mutex 是 mutual exclusion 的缩写)来确保同一时刻只有一个线程可以访问共享资源。
- 互斥量的作用类似于一个“锁”,当一个线程请求共享资源时,它必须先获取互斥量的锁。如果互斥量当前没有被其他线程占用,那么这条线程就获得锁,并可以访问共享资源;如果互斷量已经被其他线程占用,那么该线程将被阻塞,直到互斥量的锁被释放。
- 互斥量有两种状态:已锁定(locked)和未锁定(unlocked)。至多只有一个线程可以锁定该互斥量,试图再次锁定时将会阻塞,等待前一个线程释放锁。
死锁现象
当多个线程为了保护多个共享资源而使用了互斥锁,如果多个互斥锁使用不当,就可能造成,多个线程一直等待对方的锁释放,这就是死锁现象
死锁产生的四个必要条件:
- 互斥条件:资源只能被一个进程占用
- 持有并等待条件:线程1已经持有资源A,可以申请持有资源B,如果资源B已经被线程2持有,这时线程1持有资源并等待资源B
- 不可剥夺条件:一个线程持有资源,只能自己释放后其他线程才能使用。其他线程不能强制收回资源
- 环路等待条件:多个线程互相等待资源,形成一个环形等待链
避免死锁的常用方法如下:
- 锁的粒度控制:破坏请求与保持条件,尽量减少持有锁的时间,降低发生死锁的可能性
- 资源有序分配:破坏环路等待条件,规定线程使用资源的顺序,按规定顺序给资源加锁
- 重试机制:破坏不可剥夺条件,如果尝试获取资源失败,放弃已持有的资源后重试
条件变量
条件变量,通知状态的改变。 条件变量允许一个线程就某个共享变量的状态变化通知其他线程,并让其他线程等待(阻塞于)这一通知。
条件变量是结合互斥量使用。 条件变量就共享变量的状态改变发出通知,而互斁量则提供对该共享变量访问的互斥。
演示程序:
- 定义一个变量 a,线程 1 当 a 为 0 时对 a+1,线程 2 当 a 为 1 时对 a-1,循环 10 次, a 的结果应为 0 和 1 交替。
两个线程需要不断的轮询结果,造成 CPU 浪费。可以使用条件变量解决:线程 1 不满足运行条件时,先休眠等待,其他线程运行到满足条件时,通知并喚醒线程 2 继续执行。
-
(pthread_cond_t)
-
用于线程间的等待/通知机制。允许线程在某个条件不满足时阻塞自己,并等待其他线程在条件可能变为真时唤醒它。
-
必须与互斥锁配合使用。 等待操作 (pthread_cond_wait) 会原子地释放互斥锁并阻塞线程。当被唤醒时,它会重新获取互斥锁。
读写锁
读写锁, 中读锁和写锁两部分组成, 读取资源时用读锁, 修改资源时用写锁。 其特性为:写独占,读共享(读先锁)。 读写适合多写少写的场景。
读写锁的工作原理:
- 没有线程持有写锁时, 所有线程都可以一起持有读锁
- 有线程持有写锁时, 所有的读锁和写锁都会阻塞
读写优先锁: 有线程持有读锁, 这时有一个读线程和一个写线程想要获取锁, 读线程会优先获取锁 就是读优先锁, 反过来就是写优先锁。
-
(pthread_rwlock_t)
-
允许多个读线程同时访问共享资源,但只允许一个写线程独占访问。适用于读多写少的场景。
-
实现: 同样基于 futex 或类似机制。
场景分析:
- 持有读锁时,申请读锁: 全部直接加锁成功,不需要等待
- 持有写锁时,申请写锁: 申请写锁阻塞等待,写锁释放再申请加锁
- 持有读锁时, 申请写锁: 写锁阻塞
- 持有写锁时, 申请读锁: 读锁阻塞
- 持有读锁时, 申请写锁和读锁: 申请的读锁和写锁都会阻塞,当持有的写锁释放时,读锁先加锁成功
- 持有读锁时, 申请写锁和读锁: 申请的写锁阻塞,读锁加锁成功, 写锁阻塞到读锁全部解锁才能加锁,在此期间可能一直有读锁申请,会导致写锁一直无法申请成功,造成锁死
自旋锁
-
(pthread_spinlock_t)
-
一种忙等待的锁。线程在尝试获取锁失败时,不会立即阻塞,而是在一个循环中不断检查锁的状态(“自旋”),直到锁可用。
-
适用场景: 临界区非常短,且线程持有锁的时间极短,预期等待时间小于线程阻塞/唤醒的开销。在用户空间,自旋锁通常只适用于非抢占式内核或绑定到特定 CPU 的线程,否则在单核上或持有锁的线程被抢占可能导致其他线程长时间无意义自旋浪费 CPU。
-
内核中广泛使用。
屏障
-
(pthread_barrier_t)
-
允许多个线程在一个公共点(屏障)上相互等待,直到所有参与的线程都到达屏障点,然后所有线程再继续执行。
信号量
信号量是操作系统提供的一种协调共享资源访问的方法。信号量是由内核维护的整形变量(sem),它的值表示可用资源的数量
-
(sem_t - POSIX 有名/无名信号量)
-
一种更通用的同步机制,维护一个计数器。可以用来控制对多个共享资源的访问(计数信号量),也可以用作二值信号量(类似互斥锁)。
-
内核信号量 (down(), up()) 与用户空间信号量 (sem_wait(), sem_post()) 不同。 用户空间 POSIX 信号量有进程共享选项。
对信号量的原子操作:
- P操作:
- 如果有可用资源(sem>0),占用一个资源(sem-1)
- 如果没有可用资源(sem=0),进程或线程阻塞,直到有资源
- V操作:
- 如果没有进程或线程在等待资源,释放一个资源(sem+1)
- 如果有进程或线程在等待资源,唤醒一个进程或线程
P操作和V操作成对出现,进入临界区前进行P操作,离开临界区后进行V操作
生产者消费者模型
线程同步典型案例即为生产消费者模型,而借助条件变量来实现这一模型,是比较常见的一种方法。假定有两个线程,一个模拟生产者行为,一个模拟消费者行为。两个线程同时操作一个共享资源(一般称之为汇聚),生产者向其中添加产品,消费者从中消费掉产品。
相较于互斥量而言,条件变量可以减少竞争。如直接使用互斥量,除了生产者、消费者之间要竞争互斥量以外,消费者之间也需要竞争互斥量,但如果汇聚(链表)中没有数据,消费者之间竞争互斥量是无意义的。有了条件变量机制以后,只有生产者完成生产,才会引起消费者之间的竞争。提高了程序效率。