前面几篇讲了 C 语言的语法、指针、数据结构和常用技巧,这些知识足够你在单线程的世界里自由发挥了。但现实中的程序往往需要同时处理多件事情,比如一边下载文件一边响应用户操作,或者把一个大计算任务拆成几块同时执行。这就需要用到多线程编程。
这篇文章带你了解 C 语言中的多线程编程基础,包括线程的创建、线程安全问题、互斥锁的使用。
同步、异步、并行、并发
在学多线程之前,先搞清楚四个经常被混用的概念。
假设你要做三件事:煮饭、炒菜、煲汤。
最老实的做法是同步:站在锅前等饭熟,熟了再去炒菜,炒完再煲汤。一件做完才做下一件,中间你就干站着等。程序里的同步调用也是这样,调用一个函数后必须等它返回结果,才能继续执行下一行代码。
聪明一点的做法是异步:按下电饭煲的开关,不等饭熟,转身就去炒菜。炒菜过程中需要焖一会儿,你又去准备煲汤的食材。你还是只有一个人,但利用了等待的间隙切换任务,效率高了很多。
这就是异步的本质:单线程,利用等待时间切换任务。JavaScript 的 async/await、Python 的 asyncio 都是这个模型。
再高效的做法是并行:你叫了两个帮手,三个人各守一个灶台同时干活。这才是真正的"同时执行",需要多个 CPU 核心来支撑。你一个人再怎么切换任务,同一时刻也只能做一件事,但三个人可以同一时刻各做各的。
最后说并发:这是一个比较模糊的总称,只要多件事在同时推进,不管是一个人异步切换还是多个人并行执行,都可以叫并发。异步和并行都是实现并发的手段。
C 语言的并发模型是最直接的那种:通过多线程实现并发,使用 POSIX 线程库 pthread。每个线程可以被操作系统调度到不同的 CPU 核心上,实现真正的并行执行。C 的 pthread 是最底层的多线程模型,其他语言(Java、Go、Python)的线程库基本都是在它之上封装的。
创建线程
C 语言使用 pthread_create 函数创建线程。它的签名看起来有点吓人,但拆开看其实不复杂:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine)(void *), void *arg);四个参数:
thread:指向线程 ID 的指针,创建成功后会写入线程 IDattr:线程属性,传NULL表示使用默认属性start_routine:线程要执行的函数,签名必须是void* func(void* arg)arg:传给线程函数的参数,类型是void*(通用指针,可以指向任何类型)
线程创建后会立即开始运行,和主线程并行执行。主线程需要调用 pthread_join 来等待子线程结束,否则主线程一退出,整个进程就结束了,子线程也跟着被杀掉。
运行这个程序你会发现,线程 A 和线程 B 的输出是交替出现的,而且每次运行的顺序可能不一样。这是因为操作系统的线程调度是不确定的,两个线程真的在"同时"运行。
如果需要给线程传一个整数参数,不能直接把 int 强转成 void*(虽然有些编译器允许,但这不是好习惯)。标准的做法是传一个指向 int 的指针:
注意这里用了数组 args[3] 来给每个线程分配独立的参数。如果所有线程共用同一个变量(比如在循环里传 &i),由于线程启动有延迟,等线程真正读取参数时,i 的值可能已经被主线程修改了,导致多个线程拿到同一个值。
线程安全问题
多线程最大的坑在于:多个线程同时读写同一个变量时,结果可能出错。这叫做竞态条件(race condition)。
看一个经典的例子:两个线程各自对一个全局变量 counter 加 1 一百万次,最终结果应该是 2000000,对吧?
多运行几次,你会发现结果经常不是 2000000,而是一个小于 2000000 的随机数。
为什么会这样?因为 counter++ 看起来是一行代码,但在 CPU 层面其实是三个步骤:
- 从内存读取
counter的值到寄存器 - 寄存器里的值加 1
- 把结果写回内存
两个线程可能同时执行这三个步骤,导致其中一次加 1 被"吞掉"了。比如:
- 线程 A 读取
counter = 100 - 线程 B 也读取
counter = 100(还没来得及被 A 更新) - 线程 A 写入
counter = 101 - 线程 B 写入
counter = 101(覆盖了 A 的结果)
两个线程各加了一次,但 counter 只从 100 变成了 101,丢了一次。这种情况累积起来,最终结果就会小于 2000000。
互斥锁 mutex
解决竞态条件的经典方法是使用互斥锁(mutex,mutual exclusion 的缩写)。互斥锁就像一把钥匙:同一时刻只有一个线程能拿到锁,其他线程必须等这个线程释放锁之后才能继续。
用法很简单:在访问共享变量之前加锁(pthread_mutex_lock),访问完之后解锁(pthread_mutex_unlock)。
加了互斥锁之后,counter++ 这段代码在同一时刻只有一个线程能执行,另一个线程必须等锁被释放后才能进入。这样就避免了指令交错的问题,结果稳定是 2000000。
锁的使用有一个重要原则:加锁和解锁必须配对出现。如果加了锁忘了解锁,其他线程就会永远卡在那里等待。
更麻烦的情况是死锁:线程 A 持有锁 1 等待锁 2,线程 B 持有锁 2 等待锁 1,两个线程互相等对方释放,永远僵持下去。避免死锁最简单的办法是:尽量只用一把锁,如果必须用多把锁,所有线程都按相同的顺序加锁。
实际例子:多线程求和
学了线程创建和互斥锁,来看一个实际的应用场景:把一个大数组拆成几段,每个线程计算一段的和,最后汇总。
这个例子不需要互斥锁,因为每个线程只写自己的那份结果,不存在竞争。最后由主线程汇总,也不会冲突。
这个例子展示了多线程的典型用法:把一个可以拆分的任务分给多个线程并行执行,最后汇总结果。每个线程只处理数组的一段,互不干扰,所以不需要加锁。
实际开发中,这种"分而治之"的模式非常常见。数据量足够大时,多线程可以显著缩短计算时间。当然,在数据量很小的情况下,创建和管理线程本身也有开销,不一定比单线程快。
小结
这篇介绍了 C 语言多线程编程的基础知识。
核心概念回顾:同步是干完一件再干下一件,异步是一个人利用等待间隙切换任务,并行是多个人同时干,并发是这些方式的总称。C 语言通过 pthread 实现多线程并发。
用法上,pthread_create 创建线程,pthread_join 等待线程结束。多线程访问共享变量会产生竞态条件,用 pthread_mutex_lock/unlock 互斥锁来保护。如果任务可以拆分且各部分互不干扰,就不需要加锁,让每个线程独立完成自己的部分再汇总即可。
多线程编程最重要的原则只有一条:搞清楚哪些数据是共享的。共享数据必须用锁保护,不共享的数据就放心让各线程各干各的。