跳转至

并发机制

约 8908 个字 636 行代码 8 张图片 预计阅读时间 38 分钟

image.png

image.png

image.png

image.png

volatile关键字

volatile 是 C/C++ 中的核心关键字,核心作用是**告诉编译器:被修饰的变量是 “易变的”,禁止对其做任何优化(如缓存到寄存器、指令重排),必须每次都直接读写内存**—— 它是保证内存可见性的基础,尤其在并发编程、硬件交互、信号处理等场景中不可或缺。

核心特性

volatile 对变量的约束可总结为 3 个 “禁止”,直接决定了它的生效范围:

  • 禁止寄存器缓存:编译器不能将变量值缓存到 CPU 寄存器中,必须每次读写都直接访问内存(主存 / 硬件寄存器);
  • 禁止指令重排:编译器不能重排涉及该变量的内存操作(仅针对该变量,而非所有内存操作);
  • 禁止优化删除:编译器不能因为 “看似未使用” 而删除对该变量的读写操作(如硬件寄存器的写操作)。

与普通变量的对比(直观示例)

// 普通变量:编译器会优化为“寄存器缓存”
int normal_var = 0;
void test_normal() {
    normal_var = 1; // 写入寄存器
    normal_var = 2; // 编译器会优化为“直接写2”,删除前一行
    int a = normal_var; // 从寄存器读取,而非内存
}

// volatile变量:禁止所有优化
volatile int vol_var = 0;
void test_volatile() {
    vol_var = 1; // 直接写入内存,不缓存
    vol_var = 2; // 编译器不会删除前一行,两次写操作都保留
    int a = vol_var; // 直接从内存读取,而非寄存器
}

生效范围与边界

1、 生效范围(核心)

  • 作用对象:仅修饰**变量**(全局 / 局部 / 指针指向的内存),不能修饰函数 / 常量;
  • 约束层级:仅作用于**编译器**(编译期),不生成任何 CPU 指令,对 CPU 执行无影响;
  • 覆盖范围:仅针对**被修饰的单个变量**,不影响其他变量的优化;
  • 不涉及:CPU 指令重排、多 CPU 缓存一致性、原子性(这是常见误区)。

2、关键边界(哪些场景不生效)

场景 volatile 是否生效 补充说明
编译器缓存变量到寄存器 ✅ 禁止 核心作用
编译器重排该变量的读写 ✅ 禁止 仅针对该变量,其他变量仍可能重排
CPU 重排该变量的指令 ❌ 不禁止 需硬件内存屏障(如 mfence
多 CPU 缓存一致性 ❌ 不保证 需硬件屏障 + 缓存协议(如 MESI)
多线程操作的原子性 ❌ 不保证 需原子操作(如 CAS)/ 锁

典型使用场景

1、并发编程:多线程共享变量(保证可见性)

多线程共享变量时,volatile 保证线程能读到其他线程修改后的最新值(编译器不缓存):

// 全局共享标志位:必须加volatile
volatile int flag = 0;

// 线程1:修改标志位
void thread1() {
    flag = 1; // 直接写内存,不缓存到寄存器
}

// 线程2:读取标志位
void thread2() {
    while (flag == 0) { // 每次都从内存读,能及时看到thread1的修改
        // 自旋等待
    }
    printf("flag is set\n");
}

⚠️ 注意:volatile 仅保证可见性,不保证原子性!如下代码仍有并发问题:

volatile int count = 0;
// 多线程执行:count++ 是“读-改-写”三步,非原子操作,仍会竞态
void inc() {
    count++; // volatile 无法保证这一步的原子性!
}

2、硬件交互:访问内存映射寄存器

嵌入式 / 驱动开发中,硬件寄存器通常映射到内存地址,volatile 保证读写操作不被编译器优化:

// 硬件 UART 数据寄存器(内存地址 0x1000)
#define UART_DATA (*(volatile unsigned char *)0x1000)

void uart_send(char c) {
    // 写操作:必须直接写入硬件寄存器,不能被编译器优化删除
    UART_DATA = c;
    // 读操作:必须等待硬件置位,每次读内存(寄存器状态可能被硬件修改)
    while (volatile UART_STATUS & 0x01); // 等待发送完成
}

3、信号处理:信号处理函数与主函数共享变量

信号处理函数异步修改变量时,volatile 保证主函数能读到最新值:

volatile sig_atomic_t signal_received = 0;

void signal_handler(int sig) {
    signal_received = 1; // 信号处理函数异步修改
}

int main() {
    signal(SIGINT, signal_handler);
    // 主函数循环读取:每次从内存读,能及时看到信号触发
    while (!signal_received) {
        // 业务逻辑
    }
    printf("Signal received\n");
    return 0;
}

volatile 与内存屏障的区别

很多人会混淆 volatile 和内存屏障,二者互补但核心作用完全不同:

特性 volatile 编译期内存屏障(__asm__("" ::: "memory") 硬件内存屏障(mfence
约束对象 单个变量的编译器优化 所有内存操作的编译器优化 CPU 指令执行 / 缓存
核心作用 禁止寄存器缓存、单变量重排 禁止所有内存操作重排 禁止 CPU 重排、刷新缓存
原子性 不保证 不保证 不保证(需原子指令)
多 CPU 可见性 不保证 不保证 保证

互补使用示例(并发场景)

volatile int flag = 0;
int data = 0;

void producer() {
    data = 100;
    // 编译期屏障:禁止编译器重排data和flag的写操作
    __asm__ volatile ("" ::: "memory");
    // 硬件屏障:禁止CPU重排,刷新缓存(多CPU场景)
    __asm__ volatile ("mfence" ::: "memory");
    flag = 1; // volatile 保证写内存,不缓存
}

void consumer() {
    while (flag == 0); // volatile 保证每次读内存
    printf("data = %d\n", data); // 保证读到100
}

常见误区

误区 1:volatile 能保证原子性

❌ 错误:volatile 仅保证 “每次读写内存”,但无法保证 “读 - 改 - 写” 操作的原子性(如 count++);

✅ 正确:原子性需依赖 CAS(如 __sync_bool_compare_and_swap)、锁或原子指令。

误区 2:volatile 能禁止 CPU 重排

❌ 错误:volatile 仅约束编译器,CPU 仍会重排指令;

✅ 正确:需搭配硬件内存屏障(如 mfence)禁止 CPU 重排。

误区 3:所有共享变量都要加 volatile

❌ 错误:volatile 会禁用编译器优化,降低性能;仅在 “变量可能被异步修改(线程 / 信号 / 硬件)” 时使用。

误区 4:volatile 能替代内存屏障

❌ 错误:volatile 仅针对单个变量,内存屏障约束所有内存操作的顺序;

✅ 正确:并发场景需 “volatile + 内存屏障” 配合。

总结

  1. volatile 是**编译器约束关键字**,核心禁止对单个变量的寄存器缓存、指令重排、优化删除;
  2. 仅保证**内存可见性**,不保证原子性、CPU 重排、多 CPU 缓存一致性;
  3. 典型场景:多线程共享变量、硬件寄存器访问、信号处理;
  4. 与内存屏障互补使用,而非替代 ——volatile 管 “编译器不偷懒”,内存屏障管 “CPU 不捣乱”。

简单说:volatile 就像给变量贴了个 “易碎品” 标签,告诉编译器:“这个变量随时可能被修改,你别瞎优化,每次都要老老实实去内存里读写,不准偷懒用寄存器!”。

内存屏障

为什么需要这个内存屏障?

编译器为了优化性能,会对内存操作做 “不合理” 的重排或缓存,导致并发场景下逻辑错误。我们通过对比示例理解:

反例:编译器优化导致的内存操作重排

int a = 0, b = 0;
void func() {
    a = 1;  // 内存写操作
    // 编译器可能将 b=2 重排到 a=1 前,破坏逻辑
    b = 2;  // 内存写操作
}

编译器可能生成如下汇编(重排后):

movl $2, %b  # 先写 b
movl $1, %a  # 后写 a

正例:内存屏障禁止重排

int a = 0, b = 0;
void func() {
    a = 1;
    // 内存屏障:禁止重排
    __asm__ volatile ("" ::: "memory");
    b = 2;
}

编译器必须保证:a=1 的写操作在屏障前执行,b=2 的写操作在屏障后执行,无法重排。

另一个核心场景:禁止寄存器缓存内存值

volatile int flag = 0;
int data = 0;

void producer() {
    data = 100;                // 写数据
    __asm__ volatile ("" ::: "memory"); // 屏障:强制写回主存
    flag = 1;                  // 置位标志位
}

void consumer() {
    while (flag == 0);         // 等待标志位
    printf("data = %d\n", data); // 保证读到最新的 100
}
  • 若无内存屏障,编译器可能将 data=100 缓存到寄存器,未及时写回主存;
  • 此时 flag=1 先被消费者看到,但消费者读取 data 时可能拿到旧值(0),导致逻辑错误;
  • 内存屏障强制 data=100 写回主存,保证消费者能读到最新值。

编译期内存屏障

__asm__ volatile ("" ::: "memory") 是 GCC 内嵌汇编的**空指令内存屏障**,核心作用是**禁止编译器对内存读写操作进行重排和优化**,强制保证内存操作的顺序性和可见性 —— 这是底层并发编程(如原子操作、锁、内核代码)中保证正确性的关键手段,也是 Nginx、Linux 内核等高性能代码的常用技巧。

1、 GCC 内嵌汇编的标准格式:

__asm__ [volatile] ("汇编指令" : 输出约束 : 输入约束 : 破坏描述符);
部分 含义
__asm__ 声明内嵌汇编(等价于 asm,双下划线是 GNU 风格);
volatile 禁止编译器优化 / 删除该汇编块(即使指令为空);
"" 空的汇编指令(无实际 CPU 指令执行);
::: 无输出约束、无输入约束(前两个冒号为空);
"memory" 破坏描述符(Clobber List),核心作用是告诉编译器:该汇编块 “修改了内存”,需强制刷新缓存、禁止内存操作重排。

2、核心本质

这是一个**编译期内存屏障(Compiler Barrier)**,仅作用于编译器优化阶段,不生成任何 CPU 指令,但会强制编译器遵守以下规则:

  • 禁止指令重排:屏障前的所有内存读写操作,不能被编译器重排到屏障后;屏障后的内存读写操作,也不能被重排到屏障前;
  • 强制缓存刷新:编译器必须将寄存器中缓存的内存值写回主存,且后续读取必须从主存重新加载,不能复用寄存器中的旧值;
  • 不影响 CPU 执行:无硬件指令,CPU 层面的指令重排 / 缓存一致性仍需硬件内存屏障(如 x86 的 mfence)保证。

硬件内存屏障

"memory" 屏障的关键特性

(1)仅作用于编译器,不影响 CPU

  • 编译期:编译器生成汇编时,严格遵守内存操作顺序,不缓存内存值;
  • 运行期:无任何 CPU 指令执行,CPU 仍可能做指令重排(需硬件屏障补充)。

(2)与硬件内存屏障的区别(x86 为例)

类型 示例 作用范围 核心目的
编译期屏障(Compiler Barrier) __asm__ volatile ("" ::: "memory") 仅编译器 禁止编译器重排 / 缓存内存操作
硬件屏障(Hardware Barrier) asm volatile ("mfence" ::: "memory") CPU 层面 禁止 CPU 重排指令、刷新缓存一致性

(3)常见组合用法(兼顾编译 + 硬件屏障)

在并发场景中,通常需要同时禁止编译器和 CPU 重排,示例:

// x86 架构:mfence 是硬件内存屏障,搭配 memory 编译屏障
__asm__ volatile ("mfence" ::: "memory");

// ARM 架构:dmb sy 是硬件屏障
__asm__ volatile ("dmb sy" ::: "memory");

硬件内存屏障(Hardware Memory Barrier/Fence)是**CPU 提供的专用指令**,核心作用是强制约束 CPU 的指令执行顺序、刷新缓存一致性,解决多核心 / 多 CPU 场景下的内存可见性和指令重排问题 —— 它是底层并发编程(如原子操作、无锁算法、内核调度)的基石,生效范围覆盖 CPU 核心的指令流水线、缓存系统和内存总线。

1、为什么需要硬件屏障?

现代 CPU 为了提升性能,会做两类 “优化”,但在多核心场景下会破坏内存操作的正确性:

  • 指令重排:CPU 乱序执行指令(如先执行写操作 B,再执行写操作 A),导致内存操作顺序与代码逻辑不一致;
  • 缓存延迟:CPU 修改数据后先存在本地缓存(L1/L2),不立即刷到主存,其他核心无法及时看到修改。

硬件内存屏障的核心就是**禁止这些优化**,强制 CPU 按 “代码逻辑顺序” 执行内存操作,并保证修改对其他核心可见。

特性 硬件内存屏障 编译期内存屏障(如__asm__("" ::: "memory")
约束对象 CPU 硬件(指令流水线、缓存、总线) 编译器(仅约束代码编译阶段的优化)
生成指令 有(如mfence/dmb 无(空指令,仅约束编译器)
生效范围 执行该指令的 CPU 核心(跨线程 / 进程) 单个编译单元(.c 文件)
解决问题 CPU 指令重排、多核心缓存一致性 编译器指令重排、寄存器缓存

2、硬件内存屏障的分类(按操作类型)

硬件屏障按约束的内存操作类型,分为三类(不同架构指令不同,但逻辑一致):

(1)读屏障(Load Fence / LFENCE)

  • 核心作用:约束**内存读取(Load)** 操作,禁止 CPU 将 “屏障后的读操作” 重排到 “屏障前的读操作” 之前;
  • 保证:屏障前的所有读操作完成后,才执行屏障后的读操作;
  • 典型指令

    • x86/x86_64:lfence

    • ARM64:dmb ld(数据内存屏障 - 读)

    • RISC-V:fence r, rw

示例(x86 读屏障):

int a = 0, b = 0;

// 线程1
void thread1() {
    a = 1;
    b = 2;
}

// 线程2(CPU1)
void thread2() {
    int val_b = b; // 读操作1
    lfence; // 读屏障
    int val_a = a; // 读操作2
    // 屏障保证:val_b的读取一定在val_a之前完成,不会被CPU重排
}

(2)写屏障(Store Fence / SFENCE)

  • 核心作用:约束**内存写入(Store)** 操作,禁止 CPU 将 “屏障后的写操作” 重排到 “屏障前的写操作” 之前;
  • 保证:屏障前的所有写操作刷新到缓存 / 主存后,才执行屏障后的写操作;
  • 典型指令

    • x86/x86_64:sfence

    • ARM64:dmb st(数据内存屏障 - 写)

    • RISC-V:fence rw, w

示例(x86 写屏障):

int data = 0, flag = 0;

// 线程1(CPU0)
void thread1() {
    data = 100; // 写操作1
    sfence; // 写屏障:强制data写入缓存/主存
    flag = 1; // 写操作2:保证在data之后执行
}

// 线程2(CPU1)
void thread2() {
    while (flag == 0);
    printf("data = %d\n", data); // 能读到最新的100,而非缓存旧值
}

(3)全屏障(Full Fence / MFENCE)

  • 核心作用:约束**所有内存读写操作**,禁止 CPU 将屏障前的任何内存操作(读 / 写)重排到屏障后,反之亦然;
  • 保证:屏障前的所有内存操作完成并刷新到主存后,才执行屏障后的操作;
  • 典型指令

    • x86/x86_64:mfence / lock前缀(隐含全屏障)

    • ARM64:dmb sy(数据内存屏障 - 系统级)

    • RISC-V:fence rw, rw

示例(x86 全屏障):

// 最常用的全屏障写法(编译+硬件屏障结合)
__asm__ volatile ("mfence" ::: "memory");

3、主流架构的硬件屏障指令对比

不同 CPU 架构的内存模型差异(如 x86 是 “强内存模型”,ARM 是 “弱内存模型”),导致硬件屏障的指令和必要性不同:

架构 内存模型 核心屏障指令 关键特点
x86/x86_64 强内存模型 lfence/sfence/mfence 天然禁止大部分写重排,仅特殊场景需显式屏障;lock前缀(如lock cmpxchg)隐含全屏障
ARM64 弱内存模型 dmb/dsb/isb 需显式加屏障保证顺序;dmb sy是最常用的系统级全屏障
RISC-V 弱内存模型 fence 可灵活指定约束的操作类型(读 / 写)
PowerPC 弱内存模型 lwsync/sync sync是全屏障,lwsync是轻量级屏障

4、关键补充:x86 的 “隐式屏障”

x86 架构的lock前缀(如原子操作lock cmpxchgl)会自动触发以下效果,等价于全屏障:

  • 锁定内存总线,保证操作原子性;
  • 禁止 CPU 重排该指令前后的内存操作;
  • 强制刷新缓存到主存。 这也是 Nginx 的ngx_atomic_cmp_set中仅用lock前缀,无需额外mfence的原因。

5、硬件屏障的生效机制(底层原理)

硬件屏障通过以下 3 种方式保证内存操作的顺序和可见性:

  • 阻塞指令流水线:CPU 执行屏障指令时,会暂停指令流水线,直到屏障前的所有内存操作(读 / 写)完成,才继续执行后续指令 —— 禁止指令重排。
  • 刷新缓存一致性:

    • 写屏障 / 全屏障会强制将 CPU 本地缓存(L1/L2)中的修改刷到共享缓存(L3)或主存;

    • 触发 MESI 缓存一致性协议,让其他 CPU 核心的无效缓存行失效,必须重新从主存加载最新值。

  • 锁定内存总线(部分指令):如 x86 的lock前缀,会独占内存总线,禁止其他 CPU 核心在该指令执行期间访问内存,保证原子性和顺序性。

6、常见误区

误区 1:x86 架构不需要硬件屏障

❌ 错误:x86 是强内存模型,禁止 “写→读”“写→写” 重排,但 “读→写”“读→读” 仍可能重排(如 CPU 预取),特殊场景(如 IO 映射、多线程共享)仍需lfence/mfence

误区 2:硬件屏障能跨 CPU 生效

❌ 错误:硬件屏障仅对**执行该指令的 CPU 核心**生效;其他核心需通过缓存一致性协议(如 MESI)看到修改,或自己执行屏障。

误区 3:硬件屏障越多越好

❌ 错误:硬件屏障会阻塞 CPU 流水线、刷新缓存,频繁使用会降低性能(x86mfence约 5~10 纳秒 / 次),需按需使用。

7、关键点回顾

  • 硬件内存屏障是 CPU 指令,核心约束**指令执行顺序**和**缓存一致性**,解决多核心下的内存可见性问题;
  • 分三类:读屏障(约束读)、写屏障(约束写)、全屏障(约束读写),不同架构指令不同;
  • x86 是强内存模型,lock前缀隐含全屏障;ARM/RISC-V 是弱内存模型,需显式加屏障;
  • 生效范围是 “执行该指令的 CPU 核心”,跨核心一致性依赖缓存协议 + 屏障配合。

简单说:硬件内存屏障是给 CPU 的 “强制命令”——“停下你的乱序执行和缓存偷懒,按我写的顺序执行内存操作,并且把修改同步给其他核心!”。

生效范围

内存屏障的本质是 “顺序约束”:它约束的是「屏障指令前后的内存操作」,而非 “函数内 / 外”。无论屏障写在函数内还是函数外,只要内存操作跨越了屏障,就会被约束 —— 函数只是代码组织的形式,不影响屏障的核心作用范围。

举个直观例子(编译期屏障):

// 全局共享变量
int a = 0, b = 0;

// 函数内的内存屏障
void set_a() {
    a = 1;
    // 函数内的编译期屏障
    __asm__ volatile ("" ::: "memory");
}

void set_b() {
    // 屏障在 set_a() 内,但仍约束 a=1 和 b=2 的顺序
    set_a();
    b = 2;
}
  • 虽然屏障写在 set_a() 函数内部,但它依然能禁止编译器将 b=2set_b() 中)重排到 a=1set_a() 中)之前;
  • 原因:屏障的核心是 “a=1 必须在屏障前执行,所有屏障后的内存操作(包括其他函数的 b=2)必须在屏障后执行”,与函数边界无关。

生效范围:整个编译单元(.c 文件)的内存操作顺序,突破函数边界 --- 这一点存疑!!

编译器在优化时,会把整个编译单元(单个 .c 文件)作为整体重排指令,因此:

  • 只要内存操作 A 在屏障前(无论 A 在哪个函数),内存操作 B 在屏障后(无论 B 在哪个函数),编译器就不能将 B 重排到 A 之前;
  • 仅受 “编译单元” 限制(不同 .c 文件编译为独立的目标文件,屏障无法跨编译单元生效),与函数无关。

常见误区

误区 1:认为能解决 CPU 层面的重排

❌ 错误:__asm__ volatile ("" ::: "memory") 仅禁止编译器重排,CPU 仍可能重排指令;

✅ 正确:多 CPU 架构下,需搭配硬件内存屏障(如 mfence/dmb)。

误区 2:滥用内存屏障

内存屏障会**牺牲编译器优化效率**,仅在并发场景(多线程 / 多进程共享变量)中使用;单线程无共享变量时,使用内存屏障会降低性能,无任何收益。

误区 3:替代 volatile

volatile 禁止编译器缓存单个变量到寄存器,而 memory 屏障禁止所有内存操作的重排 / 缓存,二者互补而非替代:

volatile int flag = 0; // 单个变量禁止缓存
__asm__ volatile ("" ::: "memory"); // 所有内存操作禁止重排

总结

  1. __asm__ volatile ("" ::: "memory")GCC 编译期内存屏障,无实际指令执行,仅约束编译器行为;
  2. 核心作用:禁止编译器重排内存读写指令、强制刷新寄存器缓存到主存;
  3. 仅作用于编译阶段,CPU 层面的重排需搭配硬件内存屏障;
  4. 典型场景:原子操作、自旋锁、并发共享变量,保证内存可见性和顺序性。

简单说:这个空汇编指令是给编译器的 “强制命令”——“别耍小聪明优化内存操作,必须按我写的顺序来,而且要老老实实读写主存,不能偷懒用寄存器里的旧值!”。

自旋锁

核心特性

image.png

实现加锁

nginx中如何实现自旋锁:

typedef int32_t ngx_atomic_int_t;
typedef uint32_t ngx_atomic_uint_t;
typedef volatile ngx_atomic_uint_t ngx_atomic_t;

// GCC编译器实现
#define ngx_atomic_cmp_set(lock, old, set) \
__sync_bool_compare_and_swap(lock, old, set)

#if ( __i386__ || __i386 || __amd64__ || __amd64 )
// x86_64体系使用pause指令
#define ngx_cpu_pause() __asm__ ("pause")
#else
// 无指令相当于空跑
#define ngx_cpu_pause() 
#endif

// x86体系nginx手动实现
#define NGX_SMP_LOCK "lock;"
static ngx_inline ngx_atomic_uint_t
ngx_atomic_cmp_set(ngx_atomic_t *lock, ngx_atomic_uint_t old,
    ngx_atomic_uint_t set)
{
    u_char  res;
    __asm__ volatile (
         NGX_SMP_LOCK
    "    cmpxchgl  %3, %1;   "
    "    sete      %0;       "
    : "=a" (res) : "m" (*lock), "a" (old), "r" (set) : "cc", "memory");
    return res;
}

/**
 * lock为需要比较的源值,value为预期值,spin为指数回退的最长尝试时间
 */
void ngx_spinlock(ngx_atomic_t *lock, ngx_atomic_int_t value, ngx_uint_t spin)
{
    ngx_uint_t  i, n;

    for ( ;; ) {
        if (*lock == 0 && ngx_atomic_cmp_set(lock, 0, value)) {
            return;
        }
        if (ngx_ncpu > 1) {
            for (n = 1; n < spin; n <<= 1) {
                for (i = 0; i < n; i++) {
                    ngx_cpu_pause();
                }
                if (*lock == 0 && ngx_atomic_cmp_set(lock, 0, value)) {
                    return;
                }
            }
        }

        ngx_sched_yield();
    }
}

上述代码设计要点:

  • 本质上是在成功加锁之前,该函数会一直陷入循环
  • 多核CPU下,为避免持续尝试CAS操作增加性能开销,会以spin为上限指数递增尝试;
  • 大循环下,如果一轮CAS尝试失败,则yield主动让出调度:

    • 基于系统调度sched_yield让出调度,让出调度放弃剩余时间片,加入就绪队列等待下次调度,状态仍保持TASK_RUNNING,无需中断唤醒;

    • 基于sleep让出调度,状态切到TASK_INTERRUPTIBLE,需要定时器唤醒才能加入到就绪队列;

  • Nginx手搓的CAS实现,专为 x86/x86_64 架构优化,lock 前缀是 x86 原子操作的核心:强制 cmpxchgl 指令独占内存总线,禁止多 CPU 同时修改 lock 指向的内存,保证 SMP 架构下的原子性。

补充1:sched_yield() vs usleep(1) 核心差异(关键)

特性 sched_yield() usleep(1)
进程状态 就绪态(TASK_RUNNING) 可中断睡眠态(TASK_INTERRUPTIBLE)
CPU 占用 立即让出,无额外占用 休眠 1μs,期间不占用 CPU
调度器行为 重新选择就绪进程调度 进程退出调度队列,1μs 后唤醒
开销 极低(仅上下文切换) 较高(睡眠 / 唤醒 + 定时器)
唤醒时机 立即就绪,可能被快速调度 至少 1μs 后(实际可能更长)
适用场景 短时间自旋等待(如抢锁失败) sched_yield() 的兼容场景

补充2:GCC 内嵌汇编格式:__asm__ volatile ("汇编指令" : 输出约束 : 输入约束 : 破坏描述符);

约束段 内容 含义
输出约束 "=a" (res) "=a":输出到 eax 寄存器,且是 “只写”(=);

res:C 语言变量,接收 sete 指令的结果(1/0);

注:aeax 寄存器的简写(x86 32 位)。
输入约束 "m" (*lock), "a" (old), "r" (set) "m" (*lock)*lock 作为内存操作数(m=memory),对应汇编中的 %1

"a" (old)old 加载到 eax 寄存器,对应汇编中的 %2

"r" (set)set 加载到任意通用寄存器(r=register),对应汇编中的 %3

注:输入约束的序号从 %1 开始(%0 是输出)。
破坏描述符 "cc", "memory" "cc":告诉编译器,指令修改了 CPU 状态寄存器(如 ZF 零标志位),禁止编译器依赖原有标志位优化;

"memory":内存屏障,禁止编译器重排内存读写指令,保证操作的内存可见性。

实现解锁

自旋锁的 unlock(解锁)是自旋锁机制的关键一环,核心目标是**原子性释放锁,并保证解锁操作的内存可见性**,让其他自旋等待的线程能立即感知到锁已释放。

自旋锁的解锁必须满足 3 个核心条件(否则会导致并发安全问题):

  • 原子性:解锁操作(将锁变量置 0)必须是原子的,避免多线程同时解锁导致锁状态混乱;
  • 可见性:解锁后的锁状态必须立即对其他 CPU / 线程可见(禁止 CPU / 编译器缓存锁变量);
  • 有序性:解锁前的临界区操作必须全部完成,禁止编译器 / CPU 将临界区操作重排到解锁之后。

解锁逻辑的关键细节(逐行拆解)

1、内存屏障(ngx_memory_barrier()

#define ngx_memory_barrier() \
__asm__ volatile ("mfence" ::: "memory")
  • 核心作用

    • 编译期:禁止编译器将 “临界区操作”(如修改队列)重排到解锁之后;

    • 硬件层:mfence 强制 CPU 完成所有临界区的内存操作,刷新缓存到主存;

    • 保证:其他线程拿到锁后,能看到临界区的所有修改(如队列已更新)。

自旋锁解锁前加内存屏障,是为了**保证临界区操作的 “有序性” 和 “可见性”** —— 如果遗漏这一步,多 CPU / 多线程场景下会出现 “锁释放了,但临界区的修改还没同步出去” 的致命问题,导致其他线程拿到锁后读到脏数据,破坏并发安全。

  • 解锁前的内存屏障核心解决**有序性**和**可见性**问题,是自旋锁并发安全的 “最后一道防线”;
  • 无内存屏障时,编译器 / CPU 会重排指令、延迟刷新缓存,导致其他线程拿到锁后读到脏数据;
  • 内存屏障强制:临界区操作必须在解锁前完成并同步到主存,其他线程才能正确感知;
  • 单 CPU 需编译期屏障,多 CPU 需编译 + 硬件屏障。
// 全局变量
ngx_atomic_t lock = 0;       // 自旋锁(0=解锁,1=锁定)
ngx_thread_task_t *queue = NULL; // 临界区:共享队列

// 线程A:加锁→修改队列→解锁(无内存屏障)
void thread_a() {
    ngx_spinlock(&lock, 1, 0); // 加锁成功

    // 临界区:修改队列(写操作)
    ngx_thread_task_t *task = malloc(sizeof(ngx_thread_task_t));
    task->next = queue;
    queue = task;

    // 错误:解锁前无内存屏障
    lock = 0; // 解锁
}

// 线程B:自旋抢锁→读取队列
void thread_b() {
    ngx_spinlock(&lock, 1, 0); // 抢到锁

    // 读取队列:可能读到 NULL(脏数据)
    if (queue == NULL) {
        printf("队列空(错误)\n"); // 实际线程A已经修改了queue
    }

    lock = 0; // 解锁
}

2、原子置 0(*(lock) = 0

*(lock) = 0;
  • 原子性:对 ngx_atomic_t(32/64 位整型)的直接赋值是原子操作(CPU 单指令完成),无需 CAS;
  • 可见性ngx_atomic_tvolatile 修饰,禁止编译器将赋值操作缓存到寄存器,必须直接写内存;
  • 状态切换:将锁变量从 “1(锁定)” 置为 “0(解锁)”,其他自旋的线程能立即读到 0,进而抢锁。

3、为什么不用 CAS 解锁?

加锁用 CAS(ngx_atomic_cmp_set),但解锁直接置 0,核心原因:

  • 自旋锁的**持有者唯一**:只有抢到锁的线程会执行解锁,不存在 “多线程同时解锁” 的场景;
  • CAS 解锁无意义:解锁时无需比较旧值,直接置 0 即可,CAS 会增加不必要的开销;
  • 简化逻辑:保证解锁操作的极致性能

4、nginx加解锁案例

// step1: lock
ngx_spinlock(&ngx_thread_pool_done_lock, 1, 2048);

// step2: 执行临界区代码
task = ngx_thread_pool_done.first;
ngx_thread_pool_done.first = NULL;
ngx_thread_pool_done.last = &ngx_thread_pool_done.first;

// step3:先执行内存屏障,保证内存可见性
ngx_memory_barrier();
#define ngx_unlock(lock) *(lock) = 0
ngx_unlock(&ngx_thread_pool_done_lock);

互斥量

image.png

TODO:底层原理是什么?如何阻塞?如何通知的?

读写锁

读写锁(也叫共享 - 排他锁)是一种**精细化的并发同步机制**,核心思想是 “区分读操作和写操作”:允许多个读线程同时持有锁(共享模式),但写线程必须独占锁(排他模式)。这种设计能极大提升 “读多写少” 场景(如配置读取、缓存查询、日志统计)的并发效率,是自旋锁 / 互斥锁的重要补充。

核心特性

1、核心规则

  • 读锁(共享锁)

    • 无写锁持有 → 多个读线程可同时加读锁;

    • 有写锁持有 → 所有读线程阻塞,直到写锁释放。

  • 写锁(排他锁)

    • 无读 / 写锁持有 → 单个写线程可加写锁;

    • 有读 / 写锁持有 → 写线程阻塞,直到所有读 / 写锁释放。

  • 优先级:多数实现(如 POSIX)默认 “写优先”(写请求会插队,避免写线程饿死)。

特性 读写锁 自旋锁 / 互斥锁
访问模式 读共享(多个 Reader)、写排他(单个 Writer) 全排他(无论读写,仅一个线程持有)
并发效率 读多写少时极高(读线程无阻塞) 所有场景都串行(并发效率低)
阻塞策略 读锁:写锁持有则阻塞;写锁:读 / 写锁持有则阻塞 只要锁被持有,所有线程阻塞 / 自旋
适用场景 读多写少(如配置中心、缓存、统计报表) 临界区极短、读写均衡 / 写多

底层实现(pthread)

1、核心结构(简化版)

typedef struct {
    // 1. 读锁计数器:记录当前持有读锁的线程数
    int read_count;
    // 2. 写锁状态:0=未持有,1=持有
    int write_lock;
    // 3. 互斥锁:保护read_count/write_lock的原子性
    pthread_mutex_t mutex;
    // 4. 条件变量:阻塞/唤醒读/写线程
    pthread_cond_t read_cond;
    pthread_cond_t write_cond;
    // 5. 写等待数:避免写线程饿死
    int write_waiters;
} pthread_rwlock_t;

2、核心操作逻辑(伪代码)

(1)加读锁(pthread_rwlock_rdlock

int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock) {
    pthread_mutex_lock(&rwlock->mutex); // 保护内部状态

    // 有写锁持有 或 有写线程等待(写优先)→ 阻塞读线程
    while (rwlock->write_lock || rwlock->write_waiters > 0) {
        pthread_cond_wait(&rwlock->read_cond, &rwlock->mutex);
    }

    rwlock->read_count++; // 读锁计数+1
    pthread_mutex_unlock(&rwlock->mutex);
    return 0;
}

(2)加写锁(pthread_rwlock_wrlock

int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock) {
    pthread_mutex_lock(&rwlock->mutex);

    rwlock->write_waiters++; // 标记有写线程等待(写优先)
    // 有读锁持有 或 有写锁持有 → 阻塞写线程
    while (rwlock->read_count > 0 || rwlock->write_lock) {
        pthread_cond_wait(&rwlock->write_cond, &rwlock->mutex);
    }
    rwlock->write_waiters--;

    rwlock->write_lock = 1; // 独占写锁
    pthread_mutex_unlock(&rwlock->mutex);
    return 0;
}

(3)解锁(pthread_rwlock_unlock

int pthread_rwlock_unlock(pthread_rwlock_t *rwlock) {
    pthread_mutex_lock(&rwlock->mutex);

    if (rwlock->write_lock) {
        // 写锁解锁:重置写锁状态,优先唤醒写线程(写优先)
        rwlock->write_lock = 0;
        if (rwlock->write_waiters > 0) {
            pthread_cond_signal(&rwlock->write_cond);
        } else {
            // 无写等待,唤醒所有读线程
            pthread_cond_broadcast(&rwlock->read_cond);
        }
    } else {
        // 读锁解锁:计数-1,若计数为0则唤醒写线程
        rwlock->read_count--;
        if (rwlock->read_count == 0 && rwlock->write_waiters > 0) {
            pthread_cond_signal(&rwlock->write_cond);
        }
    }

    pthread_mutex_unlock(&rwlock->mutex);
    return 0;
}

读写锁的实战使用

读多写少场景:例如配置缓存

#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

#define THREAD_NUM 10

// 全局配置(读多写少)
char *config = "default_config";
// 读写锁
pthread_rwlock_t rwlock;

// 读线程:频繁读取配置
void *read_config(void *arg) {
    long tid = (long)arg;
    while (1) {
        // 加读锁(共享)
        pthread_rwlock_rdlock(&rwlock);
        printf("Reader %ld: config = %s\n", tid, config);
        // 解锁
        pthread_rwlock_unlock(&rwlock);
        usleep(100000); // 模拟读耗时
    }
    return NULL;
}

// 写线程:偶尔更新配置
void *write_config(void *arg) {
    long tid = (long)arg;
    int count = 0;
    while (1) {
        // 加写锁(排他)
        pthread_rwlock_wrlock(&rwlock);
        // 临界区:修改配置(仅写线程可执行)
        char new_config[32];
        snprintf(new_config, sizeof(new_config), "config_%d", count++);
        config = new_config;
        printf("=== Writer %ld: update config to %s ===\n", tid, config);
        // 解锁
        pthread_rwlock_unlock(&rwlock);
        sleep(1); // 模拟写耗时
    }
    return NULL;
}

int main() {
    pthread_t threads[THREAD_NUM];
    // 初始化读写锁
    pthread_rwlock_init(&rwlock, NULL);

    // 创建8个读线程,2个写线程(读多写少)
    for (long i = 0; i < 8; i++) {
        pthread_create(&threads[i], NULL, read_config, (void *)i);
    }
    for (long i = 8; i < 10; i++) {
        pthread_create(&threads[i], NULL, write_config, (void *)i);
    }

    // 等待线程结束(实际不会结束)
    for (int i = 0; i < THREAD_NUM; i++) {
        pthread_join(threads[i], NULL);
    }

    // 销毁读写锁
    pthread_rwlock_destroy(&rwlock);
    return 0;
}

编译运行

# 编译:链接pthread库
gcc rwlock_demo.c -o rwlock_demo -lpthread
# 运行
./rwlock_demo

输出效果(核心特征)

Reader 0: config = default_config
Reader 1: config = default_config
...(8个读线程同时输出,无阻塞)
=== Writer 8: update config to config_0 ===
Reader 0: config = config_0
Reader 1: config = config_0
...(写线程执行时,所有读线程阻塞,写完成后读线程继续)

读写锁的关键优化点

1、写优先 vs 读优先

  • 写优先(默认):写请求会插队,避免写线程 “饿死”(一直抢不到锁);
  • 读优先:读请求优先,并发更高,但可能导致写线程饿死;
  • 可通过读写锁属性(pthread_rwlockattr_t)配置:
pthread_rwlockattr_t attr;
pthread_rwlockattr_init(&attr);
// 设置写优先(POSIX默认,部分系统需显式配置)
pthread_rwlockattr_setkind_np(&attr, PTHREAD_RWLOCK_PREFER_WRITER_NP);
pthread_rwlock_init(&rwlock, &attr);

2、自旋读写锁(Nginx / 内核级实现)

POSIX 读写锁是 “阻塞式”(依赖条件变量),而 Nginx/Linux 内核实现了**自旋读写锁**(纯用户态,无内核切换),核心优化:

  • 读锁:原子递增计数器(无自旋,直接成功);
  • 写锁:自旋等待读计数器为 0,再原子置位写锁;
  • 适用场景:临界区极短(<100 纳秒)的读多写少场景。

Nginx 自旋读写锁核心逻辑(简化)

typedef struct {
    ngx_atomic_t readers; // 读计数器(负数表示写锁持有)
} ngx_rwlock_t;

// 加读锁:readers++(原子操作)
#define ngx_rwlock_rdlock(lock)                                            \
do {                                                                       \
    ngx_atomic_fetch_add(&lock->readers, 1);                               \
    // 若readers为负(写锁持有),自旋等待                                 \
    while (ngx_atomic_cmp_set(&lock->readers, -1, -1)) {                   \
        ngx_cpu_pause();                                                   \
    }                                                                      \
} while (0)

// 加写锁:自旋等待readers=0,再置为-1(排他)
#define ngx_rwlock_wrlock(lock)                                            \
do {                                                                       \
    while (!ngx_atomic_cmp_set(&lock->readers, 0, -1)) {                   \
        ngx_cpu_pause();                                                   \
    }                                                                      \
} while (0)

// 解锁:读锁则readers--,写锁则置为0
#define ngx_rwlock_unlock(lock)                                            \
do {                                                                       \
    if (ngx_atomic_fetch_add(&lock->readers, -1) == -1) {                  \
        ngx_atomic_set(&lock->readers, 0);                                 \
    }                                                                      \
} while (0)

读写锁的常见坑点

1、读锁不保证 “最新性”(需配合内存屏障)

多个读线程共享锁时,若有写线程更新数据,读线程需依赖内存屏障才能看到最新值:

// 写线程:更新后加内存屏障
pthread_rwlock_wrlock(&rwlock);
config = new_config;
__asm__ volatile ("mfence" ::: "memory"); // 硬件屏障
pthread_rwlock_unlock(&rwlock);

// 读线程:读取前加内存屏障
pthread_rwlock_rdlock(&rwlock);
__asm__ volatile ("lfence" ::: "memory"); // 读屏障
printf("config = %s\n", config);
pthread_rwlock_unlock(&rwlock);

2、避免 “读锁升级为写锁”

读线程持有读锁时,尝试加写锁会导致**死锁**(自己阻塞自己):

// 错误:读锁未释放,加写锁→死锁
pthread_rwlock_rdlock(&rwlock);
pthread_rwlock_wrlock(&rwlock); // 阻塞,且其他读线程也持有锁→永久死锁

// 正确:先释放读锁,再加写锁
pthread_rwlock_rdlock(&rwlock);
// 读取数据
pthread_rwlock_unlock(&rwlock);
pthread_rwlock_wrlock(&rwlock);
// 修改数据

3、读写锁并非 “银弹”

  • 写操作频繁时,读写锁效率不如互斥锁(写锁排他,且有额外的计数器开销);
  • 临界区极短时,自旋锁 / 互斥锁的开销更低(读写锁的计数器操作有额外开销)。

总结

  1. 读写锁核心是 “读共享、写排他”,极大提升读多写少场景的并发效率;
  2. 底层实现分两类:POSIX 阻塞式(依赖互斥锁 + 条件变量)、内核 / NGINX 自旋式(纯用户态);
  3. 核心规则:写锁排他,读锁共享;默认写优先,避免写线程饿死;
  4. 常见坑:读锁升级写锁会死锁、读线程需内存屏障保证数据最新性。

简单说:读写锁就像 “图书馆规则”—— 多人可同时看书(读共享),但有人要修改书籍内容(写排他)时,必须等所有人看完,且修改时不允许任何人看书;修改完成后,又能多人同时看,既保证了并发,又保证了修改的独占性。

文件锁

文件锁是**操作系统提供的、基于文件描述符的同步机制**,核心作用是保证多个进程(甚至跨机器)对同一文件的读写操作互斥 / 共享,避免文件数据错乱。它和自旋锁、读写锁的核心区别是:文件锁作用于**文件**而非内存变量,支持跨进程、跨会话,甚至通过网络文件系统(NFS)跨机器同步。

核心特性

特性 文件锁(flock/fcntl) 内存锁(自旋锁 / 读写锁)
作用范围 跨进程、跨机器(NFS) 仅进程内多线程
同步对象 文件(文件描述符 / 路径) 内存变量
阻塞策略 阻塞 / 非阻塞(内核态等待) 自旋(用户态)/ 阻塞(内核态)
持久化 锁随文件描述符关闭释放,支持强制解锁 锁随进程退出释放
适用场景 多进程共享文件(如日志、配置、数据库) 进程内多线程共享内存

文件锁主要分两类,对应不同的系统调用:

  1. 建议性锁(Advisory Lock):仅 “约定” 遵守锁规则,不强制(进程可无视锁直接读写);
  2. 强制性锁(Mandatory Lock):内核强制生效,无视锁的进程读写会被阻塞 / 报错(需文件系统开启);

实际开发中 99% 用**建议性锁**(强制性锁性能差、兼容性低)。

两种核心实现

简易文件锁:flock(整文件锁)

flock 是简化版文件锁,只能锁定整个文件(无法锁定文件部分),接口简单,适合大部分场景。

1、核心接口

#include <sys/file.h>

// 加锁/解锁
// fd:文件描述符(必须以读写/只读/只写方式打开)
// operation:
//   LOCK_SH:共享锁(读锁,多个进程可同时持有)
//   LOCK_EX:排他锁(写锁,仅一个进程持有)
//   LOCK_UN:解锁
//   LOCK_NB:非阻塞(加锁失败立即返回,不阻塞)
int flock(int fd, int operation);

实战示例(多进程写日志,排他锁)

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/file.h>
#include <fcntl.h>
#include <pthread.h>

// 写日志函数(加排他锁保证原子写)
void write_log(const char *msg) {
    // 打开文件(O_APPEND保证写在末尾,O_CREAT创建文件)
    int fd = open("app.log", O_WRONLY | O_APPEND | O_CREAT, 0644);
    if (fd < 0) {
        perror("open failed");
        return;
    }

    // 加排他锁(阻塞式,直到拿到锁)
    if (flock(fd, LOCK_EX) < 0) {
        perror("flock failed");
        close(fd);
        return;
    }

    // 临界区:写日志(原子操作)
    write(fd, msg, strlen(msg));
    write(fd, "\n", 1);

    // 解锁
    flock(fd, LOCK_UN);
    // 关闭文件(即使忘解锁,关闭fd也会自动释放锁)
    close(fd);
}

// 子进程函数:循环写日志
void *child_process(void *arg) {
    long pid = (long)arg;
    char msg[64];
    for (int i = 0; i < 5; i++) {
        snprintf(msg, sizeof(msg), "Process %ld: log %d", pid, i);
        write_log(msg);
        usleep(100000); // 模拟耗时
    }
    return NULL;
}

int main() {
    // 创建3个进程写日志
    for (long i = 0; i < 3; i++) {
        pid_t pid = fork();
        if (pid == 0) { // 子进程
            child_process((void *)i);
            exit(0);
        } else if (pid < 0) {
            perror("fork failed");
        }
    }

    // 等待所有子进程结束
    for (int i = 0; i < 3; i++) {
        wait(NULL);
    }
    printf("All processes done. Check app.log\n");
    return 0;
}

编译运行

gcc file_lock_flock.c -o file_lock_flock
./file_lock_flock
# 查看日志(无乱序、无重叠)
cat app.log

输出效果(核心:日志行完整,无重叠)

Process 0: log 0
Process 1: log 0
Process 2: log 0
Process 0: log 1
Process 1: log 1
...

高级文件锁:fcntl(细粒度锁)

fcntl 是更强大的文件锁接口,支持:

  • 锁定文件的**部分区域**(如第 100-200 字节);
  • 跨进程传递锁;
  • 自定义锁的行为(如信号通知);
  • 兼容 POSIX 标准,跨平台性更好。

核心接口

#include <fcntl.h>

// 锁结构:描述要锁定的文件区域
struct flock {
    short l_type;    // 锁类型:F_RDLCK(读锁)、F_WRLCK(写锁)、F_UNLCK(解锁)
    short l_whence;  // 偏移起始位置:SEEK_SET(文件开头)、SEEK_CUR(当前位置)、SEEK_END(文件末尾)
    off_t l_start;   // 起始偏移(字节)
    off_t l_len;     // 锁定长度(字节,0表示到文件末尾)
    pid_t l_pid;     // 持有锁的进程ID(仅用于查询)
};

// 加锁/解锁/查询锁
// fd:文件描述符
// cmd:
//   F_SETLK:设置锁(非阻塞,失败返回-1)
//   F_SETLKW:设置锁(阻塞,直到拿到锁)
//   F_GETLK:查询锁(填充flock结构,返回持有锁的进程)
int fcntl(int fd, int cmd, struct flock *lock);

实战示例(锁定文件部分区域)

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <string.h>

// 锁定文件的指定区域
int lock_file_region(int fd, off_t start, off_t len, int is_write) {
    struct flock lock = {0};
    lock.l_whence = SEEK_SET;
    lock.l_start = start;
    lock.l_len = len;
    // 读锁/写锁
    lock.l_type = is_write ? F_WRLCK : F_RDLCK;

    // 阻塞式加锁
    return fcntl(fd, F_SETLKW, &lock);
}

// 解锁文件区域
int unlock_file_region(int fd, off_t start, off_t len) {
    struct flock lock = {0};
    lock.l_whence = SEEK_SET;
    lock.l_start = start;
    lock.l_len = len;
    lock.l_type = F_UNLCK;

    return fcntl(fd, F_SETLK, &lock);
}

int main() {
    // 打开文件(读写模式)
    int fd = open("data.bin", O_RDWR | O_CREAT, 0644);
    if (fd < 0) {
        perror("open failed");
        return -1;
    }

    // 锁定文件第0-100字节(写锁)
    if (lock_file_region(fd, 0, 100, 1) < 0) {
        perror("lock failed");
        close(fd);
        return -1;
    }

    // 临界区:修改第0-100字节
    char data[100] = "Hello File Lock!";
    lseek(fd, 0, SEEK_SET);
    write(fd, data, strlen(data));

    // 解锁
    unlock_file_region(fd, 0, 100);
    close(fd);
    return 0;
}

关键细节

1、锁的释放规则(必须牢记)

  • flock:锁与**文件描述符**绑定,关闭 fd 会自动释放锁(即使忘调用LOCK_UN);
  • fcntl:锁与**进程 + 文件**绑定,进程退出会自动释放锁;
  • 注意:dup/fork复制的 fd,flock会认为是同一个锁(共享),fcntl则是独立锁。

2、非阻塞加锁(避免死锁)

// flock非阻塞加锁
if (flock(fd, LOCK_EX | LOCK_NB) < 0) {
    if (errno == EWOULDBLOCK) {
        printf("文件已被锁定,非阻塞加锁失败\n");
        return -1;
    }
}

// fcntl非阻塞加锁
struct flock lock = {0};
lock.l_type = F_WRLCK;
lock.l_whence = SEEK_SET;
lock.l_start = 0;
lock.l_len = 0;
if (fcntl(fd, F_SETLK, &lock) < 0) {
    if (errno == EAGAIN || errno == EACCES) {
        printf("文件区域已被锁定\n");
        return -1;
    }
}

3、建议性锁 vs 强制性锁

  • 建议性锁(默认):所有进程必须 “主动加锁 / 检查锁” 才生效,若有进程直接读写文件,锁会失效;
  • 强制性锁:需满足两个条件:

    • 文件挂载时开启-o mand(如mount -o mand /dev/sda1 /mnt);

    • 文件设置setgid位,且组执行位为 0(chmod g+s,g-x file);

强制性锁性能差,仅在必须强制约束所有进程时使用。

4、跨机器文件锁(NFS)

  • flock:不支持 NFS(仅本地文件系统有效);
  • fcntl:支持 NFS v3+(需服务端开启锁服务);
  • 跨机器场景优先用fcntl,或借助分布式锁(如 Redis/ZooKeeper)。

文件锁的常见坑点

1、只写 / 只读打开文件的锁限制

  • 加**读锁(LOCK_SH/F_RDLCK)**:文件必须以O_RDONLY/O_RDWR打开;
  • 加**写锁(LOCK_EX/F_WRLCK)**:文件必须以O_WRONLY/O_RDWR打开;

错误示例:

int fd = open("file.txt", O_RDONLY);
flock(fd, LOCK_EX); // 失败!只读模式无法加写锁

2、多个 fd 指向同一文件的锁冲突

fork/dup复制的 fd,flock会认为是 “同一个锁”(不会冲突),但fcntl会认为是 “不同锁”(可能死锁):

int fd1 = open("file.txt", O_RDWR);
int fd2 = dup(fd1);
flock(fd1, LOCK_EX);
flock(fd2, LOCK_EX); // 成功(同一锁)

struct flock lock = {.l_type = F_WRLCK, .l_whence = SEEK_SET, .l_start = 0, .l_len = 0};
fcntl(fd1, F_SETLKW, &lock);
fcntl(fd2, F_SETLKW, &lock); // 死锁(不同锁,互相等待)

3、忘解锁导致锁残留

虽然关闭 fd 会自动解锁,但长期打开 fd 且忘解锁,会导致其他进程一直阻塞:

int fd = open("file.txt", O_RDWR);
flock(fd, LOCK_EX);
// 忘记解锁,且fd未关闭 → 锁一直持有
// 解决:用RAII(C++)或goto保证解锁/关闭fd

总结

  1. 文件锁是跨进程的同步机制,分flock(整文件,简单)和fcntl(细粒度,强大);
  2. 核心类型:共享锁(读)、排他锁(写),默认是建议性锁;
  3. 锁与 fd / 进程绑定,关闭 fd / 进程退出会自动释放锁;
  4. 适用场景:多进程共享文件(日志、配置、数据库文件),跨进程同步;
  5. 跨机器优先用fcntl(支持 NFS),或分布式锁。

简单说:文件锁就像 “文件的门禁卡”—— 共享锁是 “多人可同时进门看书”,排他锁是 “仅一人进门修改”;flock是简易门禁(管整个房子),fcntl是高级门禁(管单个房间),核心都是保证文件操作的原子性和一致性。

条件变量

互斥量解决的是多线程并发读写的安全问题,条件变量解决的是多线程下协作的通知和等待问题。两者是配合关系,解决的不同问题。条件变量典型应用在生产消费问题上。

image.png

image.png

RCU

各类锁场景对比

在多进程 / 多线程同步场景中,锁的设计围绕 “资源独占、并发优化、场景适配” 三大目标,核心可分为 基础独占锁、并发优化锁、跨进程锁、特殊场景锁 四大类,下面按 “类型定义 + 核心特性 + 适用场景” 拆解,附选型优先级,新手也能快速匹配场景。


一、基础独占锁(核心基石,必掌握)

这类锁的核心是 “保证资源独占访问”,是所有同步锁的基础,适用于绝大多数基础场景。

锁类型 核心定义 核心特性 适用场景
互斥锁(Mutex) 二元锁(0/1),同一时间仅 1 个线程 / 进程持有,保证资源独占 ✅ 有所有权(仅加锁者可解锁)

✅ 获取失败则阻塞(让出 CPU)

❌ 不支持并行读
通用独占场景:文件读写、全局变量修改、临界区代码执行(读写均衡 / 写多读少)
递归互斥锁(RLock) 互斥锁的扩展,允许同一线程多次加锁(需对应次数解锁) ✅ 解决 “递归调用加锁” 问题

✅ 其他特性同 Mutex
递归函数、嵌套加锁场景(如函数 A 调用函数 B,两者都需加锁)
自旋锁(Spin Lock) 忙等版互斥锁,获取失败时不阻塞,循环检测锁状态(不放弃 CPU) ✅ 无上下文切换开销(性能极高)

❌ 自旋耗 CPU

✅ 多核场景优势明显
多核 CPU + 临界区极短(<10 微秒):内核态代码、高频内存操作、低延迟交易系统

关键提醒

  • 自旋锁 仅适用于多核 + 短临界区,单核 / 长临界区使用会导致 CPU 100% 占用;
  • 递归互斥锁需严格保证 “加锁次数 = 解锁次数”,否则会死锁。

二、并发优化锁(读多写少 / 多资源场景)

这类锁是基础锁的 “场景化优化”,解决基础锁 “全量串行” 导致的性能浪费问题。

锁类型 核心定义 核心特性 适用场景
读写锁(Read-Write Lock) 分读锁(共享锁)、写锁(排他锁):多读并行、写写 / 读写互斥 ✅ 读多写少场景性能远超 Mutex

✅ 可配置优先级(读优先 / 写优先)

❌ 实现复杂
读占比≥80% 的场景:配置中心、缓存查询、日志查看、商品库存查询
信号量(Semaphore) 计数锁(0~N),控制同时访问资源的线程 / 进程数(本质是 “多资源版 Mutex”) ✅ 无所有权(任意线程可释放)

✅ 支持同步 + 互斥

❌ 易误释放导致异常
有限资源池控制:连接池(最多 10 个连接)、线程池、限流(最多 5 个并发请求)
条件变量(Condition) 基于 Mutex 的扩展,实现 “等待 - 唤醒” 机制(非纯锁,配合锁使用) ✅ 精准唤醒指定线程 / 所有线程

✅ 解决 “轮询等待” 问题
生产者 - 消费者模型、任务排队执行、线程间同步通信

关键提醒

  • 信号量不是 “纯锁”,但常被归为同步锁范畴,不要用信号量实现简单互斥(无所有权,易出错);
  • 读写锁避免 “读锁升级为写锁”(先加读锁再加写锁会导致死锁)。

三、跨进程锁(多进程场景专属)

多线程锁(如 Python threading.Lock)仅在进程内有效,跨进程需用内核级 / 文件级锁。

锁类型 核心定义 核心特性 适用场景
文件锁(fcntl/portalocker) 基于文件的跨进程锁,通过内核维护文件锁状态 ✅ 跨进程 / 跨机器(网络文件)

✅ 兼容所有系统

❌ 性能略低(涉及文件 IO)
多进程共享文件、跨进程资源独占(如多进程写同一个日志文件)
系统 V 共享内存锁(shmctl) 基于 System V 共享内存的跨进程锁 ✅ 纯内存操作(性能高)

❌ 接口老旧、移植性差
老系统跨进程同步、高性能跨进程内存共享
POSIX 互斥锁(PThread Mutex) 跨进程的 POSIX 标准锁(需映射到共享内存) ✅ 性能高、接口统一

✅ 支持进程 / 线程级锁定
现代 Linux/Unix 多进程同步、跨进程临界区保护
分布式锁(Redis/ZooKeeper) 跨机器 / 跨集群的分布式锁(非系统级锁,属于应用层锁) ✅ 跨网络、跨集群

✅ 支持超时、重入

❌ 依赖中间件
分布式系统:分布式任务调度、跨机器资源独占、秒杀系统库存保护

关键提醒

  • Python 多进程推荐用 multiprocessing.Lock(封装了 POSIX 锁),无需手动实现文件锁;
  • 分布式锁优先选 Redis(高性能)或 ZooKeeper(高可靠),避免手动实现(易出超时 / 死锁问题)。

四、特殊场景锁(解决特定问题)

这类锁针对 “死锁、公平性、优先级” 等特殊需求设计,属于进阶用法。

锁类型 核心定义 核心特性 适用场景
公平锁(Fair Lock) 按 “先到先得” 顺序分配锁,避免线程饥饿 ✅ 解决 “锁抢占不均” 问题

❌ 性能略低(需维护等待队列)
对公平性要求高的场景:任务调度、资源分配
重入锁(Reentrant Lock) 同 “递归互斥锁(RLock)”,允许同一线程多次加锁 ✅ 避免递归调用死锁

✅ 有所有权保护
嵌套函数加锁、回调函数加锁
屏障锁(Barrier) 等待所有线程到达指定点后,再一起执行 ✅ 实现 “同步点” 机制

✅ 支持超时、重置
多线程协同任务:数据分片计算(所有分片计算完成后汇总)
死锁检测锁(Deadlock Detection Lock) 内置死锁检测逻辑,触发死锁时报警 / 解锁 ✅ 调试 / 监控场景实用

❌ 性能开销大
开发 / 测试阶段、复杂锁嵌套场景(定位死锁问题)

五、锁选型优先级(新手直接套用)

(1)多线程场景

  • 通用独占 → Mutex(threading.Lock)
  • 读多写少 → 读写锁(第三方库如rwlock);
  • 递归 / 嵌套加锁 → RLock(threading.RLock)
  • 多核 + 短临界区 → 自旋锁(Python 需模拟,C/C++ 原生支持);
  • 线程同步通信 → Condition(threading.Condition)

(2)多进程场景

  • 简单跨进程独占 → multiprocessing.Lock
  • 多进程共享文件 → 文件锁(portalocker)
  • 高性能跨进程内存共享 → POSIX 互斥锁 / System V 锁
  • 跨机器 / 集群 → Redis/ZooKeeper 分布式锁

六、避坑总原则

  1. 优先用 “最简单的锁”:能用水 Mutex 解决的,不用读写锁 / 信号量(减少复杂度);
  2. 跨进程锁优先选 “内核级”(如multiprocessing.Lock),而非手动实现文件锁;
  3. 自旋锁仅用于 “多核 + 极短临界区”,否则不如 Mutex 高效;
  4. 分布式锁必须加 “超时机制”,避免网络异常导致死锁。

七、锁总结

  1. 核心分类:基础独占锁(Mutex / 自旋锁)→ 并发优化锁(读写锁 / 信号量)→ 跨进程锁(文件锁 / 分布式锁)→ 特殊场景锁(公平锁 / 屏障锁);
  2. 选型核心:先看 “线程 / 进程”→ 再看 “读写比例 / 资源数量”→ 最后看 “特殊需求(公平性 / 递归)”;
  3. 关键提醒:锁的本质是 “牺牲并发换安全”,避免过度加锁(如无竞争的代码段加锁)。