OS-并发-从入门到放弃

放弃

进入并发领域,我们将要放弃很多默认的条件:

  1. 原子性
    "程序独占处理器执行"的假设不再成立。程序可能在运行中被打断,甚至一条汇编指令也可能被中断。例如,更新共享变量时可能因中断导致数据不完整。为解决此问题,需通过同步原语(如锁)将操作封装为临界区,确保原子性。
  2. 顺序性
    编译器将代码视为单线程优化,可能导致多线程中变量未被实际访问。例如,若同步变量因编译器重排而未被及时刷新,将引发竞态条件(race condition)。这要求开发者显式使用内存屏障或原子操作保证可见性。
  3. 可见性
    现代处理器通过指令重排和缓存优化提升性能,但可能导致线程间数据不一致。例如,线程A的写入可能因缓存未同步而对线程B不可见。需通过硬件支持(如CPU内存模型)和操作系统机制(如缓存行冲洗)保证可见性。

并发 vs 并行

特征 并发(Concurrency) 并行(Parallelism)
执行方式 逻辑上同时执行(时间片轮转模拟) 物理上真正同时执行(多核支持)
核心需求 协调资源共享与执行顺序 充分利用多核提升性能
典型场景 响应式系统(如Web服务器) 计算密集型任务(如矩阵运算)

线程

线程和进程区别:

  • 线程共享进程的虚拟地址空间,但拥有独立寄存器和栈。因此线程切换需保存/恢复寄存器状态,但无需切换页表(与进程相比显著降低开销)。
  • 线程和进程的栈不同:
    • 进程:单栈位于地址空间底部,向上生长。
    • 线程:多栈分散在地址空间中,每个线程独立管理自身栈。虽然可能导致地址空间布局碎片化,但提升并发效率。

实验

为了测试多线程是否真正的在不同核心上并行执行,可以用 sched_getcpu() 打印当前线程的 CPU 核心编号,在一个有 16 个逻辑核心的处理器上开启 16 个线程,结果如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# 以下结果使用 sort 排序过
Thread 0 is running on CPU core 4
Thread 1 is running on CPU core 10
Thread 2 is running on CPU core 12
Thread 3 is running on CPU core 1
Thread 4 is running on CPU core 6
Thread 5 is running on CPU core 4
Thread 6 is running on CPU core 10
Thread 7 is running on CPU core 4
Thread 8 is running on CPU core 10
Thread 9 is running on CPU core 1
Thread 10 is running on CPU core 12
Thread 11 is running on CPU core 10
Thread 12 is running on CPU core 8
Thread 13 is running on CPU core 12
Thread 14 is running on CPU core 4
Thread 15 is running on CPU core 10

操作系统(如 Linux 的 CFS 调度器)会尝试将线程分散到不同核心以实现负载均衡。同时,调度器优先将线程保留在同一核心以利用缓存局部性(cache affinity),但实验中部分线程仍被迁移,说明调度器动态调整以应对系统负载变化。

线程栈

编写一段测试代码,用以观察栈的分布情况:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
#define NUM_THREADS 5

void *test_func() {
  char c = 'a';
  printf("%p\n", &c);
}

int main() {
  pthread_t thread_id[NUM_THREADS];

  for (int i = 0; i < NUM_THREADS; i++) {
    int ret = pthread_create(&(thread_id[i]), NULL, test_func, NULL);
  }
  for (int i = 0; i < NUM_THREADS; i++) {
    pthread_join(thread_id[i], NULL);
  }
  return 0;
}

使用 mmap-md.py 观察进程的地址空间,可以发现存在多个匿名内存区域。这就是每个线程自己独立的栈空间,但共享全局变量

Start Address End Address Size Offset Permissions Mapped File
0x7ffff53fb000 0x7ffff53fc000 0x1000 0x0 ---p [anonymous]
0x7ffff53fc000 0x7ffff5bfc000 0x800000 0x0 rw-p [anonymous]
0x7ffff5bfc000 0x7ffff5bfd000 0x1000 0x0 ---p [anonymous]
0x7ffff5bfd000 0x7ffff63fd000 0x800000 0x0 rw-p [anonymous]
0x7ffff63fd000 0x7ffff63fe000 0x1000 0x0 ---p [anonymous]
0x7ffff63fe000 0x7ffff6bfe000 0x800000 0x0 rw-p [anonymous]
0x7ffff6bfe000 0x7ffff6bff000 0x1000 0x0 ---p [anonymous]
0x7ffff6bff000 0x7ffff73ff000 0x800000 0x0 rw-p [anonymous]
0x7ffff73ff000 0x7ffff7400000 0x1000 0x0 ---p [anonymous]
0x7ffff7400000 0x7ffff7c00000 0x800000 0x0 rw-p [anonymous]

放弃原子性

从学会 if 语句之后,我们编写代码是有一个默认的约定:

1
2
3
if(a==0){
  // 这个区域内一定是 a==0 才会进入
}

试着在多线程环境中执行以下函数:

1
2
3
4
5
6
7
8
void *thread_function(void *arg) {
  if (flag) {
    sleep(2);
    printf("flag is: %d\n", flag);
    flag = false;
  }
  return NULL;
}

输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
flag is: 1
flag is: 1
flag is: 1
flag is: 1
flag is: 0
flag is: 0
flag is: 0
flag is: 0
flag is: 0
flag is: 0

这是由于在进行 if 语句判断时 flag 为 true,但 sleep 时其他线程修改 flag 为 false,但此时已经进入 if 内,代码将打印出 0 值。

放弃顺序性

使用两个线程运行以下函数:

1
2
3
4
5
6
#define N 1000000
void *thread_function(void *arg) {
  for (int i = 0; i < N; i++) {
    sum++;
  }
}

输出:

1
2
3
4
5
6
❯ gcc -O2 -o main ./main.c && ./main
2000000
❯ gcc -O1 -o main ./main.c && ./main
1000000
❯ gcc -o main ./main.c && ./main    
1204008

这是由于编译器假定代码是单线程执行,编译器只保证单线程下的优化是无害的。因此随着优化等级的提高,编译器会倾向于直接合并步骤。这进一步导致并发程序的 BUG 存在随机性。

updatedupdated2025-04-132025-04-13