写C语言多线程,很多人第一反应是头大。
Pthread库那一堆结构体,看着就让人想放弃。
但其实,多线程就像是在厨房做菜。
你一个人切菜、炒菜、摆盘,忙得脚不沾地。
两个人分工,一个负责切,一个负责炒,效率翻倍。
C语言的多线程,就是让你亲手搭建这个厨房。
今天咱们不聊晦涩的理论,直接上手实操。
看看怎么把这个“厨房”打理得井井有条。
别让线程变成“抢椅子”游戏
初学者最容易踩的坑,就是数据竞争。
你想象一下,两个线程同时修改同一个全局变量。
线程A读到值是1,准备加1。
线程B也读到值是1,准备加1。
结果呢?两个线程都写回2。
本该是3的结果,变成了2。
这就是典型的数据竞争,也是多线程编程的“鬼故事”。
解决这个问题的核心,就是互斥锁(Mutex)。
互斥锁就像厨房里的对讲机。
谁拿到对讲机,谁才能进厨房操作。
别人想进来?对不起,先排队。
在C语言里,用pthread_mutex_lock和pthread_mutex_unlock就能搞定。
代码写起来其实很简单,但逻辑要严密。
一旦忘记解锁,或者锁的位置不对,程序就死锁了。
死锁比崩溃还难调试,因为程序可能还在跑,就是不动了。
所以,养成“谁申请,谁释放”的习惯至关重要。
最好把锁的操作封装起来,减少出错概率。
信号量:让线程学会“等待”
有时候,线程之间不是要抢资源,而是要等信号。
比如,生产者生产数据,消费者消费数据。
缓冲区满了,生产者得等。
缓冲区空了,消费者也得等。
这时候,互斥锁就不够用了,你需要信号量(Semaphore)。
信号量就像是一个计数器,或者一个通行证。
初始值为0,意味着没有通行证。
线程A申请通行证,拿不到,那就乖乖睡觉(阻塞)。
线程B生成数据,放一个通行证,线程A醒来。
这就是经典的“生产者-消费者”模型。
在C语言中,sem_init和sem_wait是核心函数。
注意,信号量分为二值信号量和计数信号量。
二值信号量类似互斥锁,但语义不同。
计数信号量更适合资源池的管理。
比如你有10个数据库连接,这就是一个计数为10的信号量。
线程来一个,拿走一个连接,信号量减1。
用完归还,信号量加1。
这样既能控制并发数量,又能避免资源耗尽。
很多初学者分不清互斥锁和信号量的区别。
简单说,互斥锁保护临界区,信号量协调流程。
别把两者混用,否则逻辑会乱成一锅粥。
条件变量:线程间的“悄悄话”
刚才说的信号量,适合简单的计数和等待。
但有些场景,等待的条件很复杂。
比如,不仅要缓冲区不满,还要特定类型的消息。
这时候,条件变量(Condition Variable)就派上用场了。
条件变量总是和互斥锁绑在一起。
它的作用是让线程在满足特定条件时“醒过来”。
想象你在等快递。
你一直盯着手机(忙等待),太累,也浪费电。
你设置个闹钟(条件变量),到了时间再起来看。
如果快递提前到了,快递员会敲你家门(通知/广播)。
在代码里,pthread_cond_wait会让线程释放锁并休眠。
pthread_cond_signal或pthread_cond_broadcast则负责唤醒。
这里有个经典的陷阱:伪唤醒。
线程可能在没收到信号的情况下醒来。
所以,等待条件必须放在while循环里,而不是if。
检查条件,不满足继续睡;满足再干活。
这是多线程编程的铁律,千万别偷懒。
另外,广播(broadcast)和通知(signal)的区别也要分清。
广播是叫醒所有人,通知只叫醒一个。
用广播要小心,容易引发“惊群效应”,导致性能抖动。
原子操作:轻量级的同步手段
有时候,你的操作很简单。
比如,对一个整数加1。
用互斥锁有点杀鸡用牛刀,性能开销大。
这时候,原子操作(Atomic Operations)是更好的选择。
原子操作保证指令执行的不可分割性。
在C11标准中,引入了stdatomic.h头文件。
__atomic_add_fetch、__atomic_load这些函数,直接操作内存。
它们不依赖操作系统级的锁,速度极快。
特别适合高并发的计数器、状态标志等场景。
当然,原子操作也有局限。
它只能保证单个变量的操作是原子的。
如果你要更新多个关联变量,还得靠锁。
比如,更新缓存命中率,既要改计数器,又要改统计数组。
这时候,原子操作就搞不定了。
所以,别迷信原子操作,要看场景。
简单状态同步,用原子;复杂逻辑同步,用锁。
线程池:别让线程“裸奔”
最后,聊聊线程池。
很多新手喜欢pthread_create一把梭。
来一个任务,创建一个线程。
做完任务,立即销毁。
这在任务少的时候没问题。
但在高并发场景下,频繁创建和销毁线程,开销巨大。
线程的创建涉及内核态切换,内存分配。
销毁涉及资源回收。
这些动作加起来,可能比任务本身还慢。
线程池的思路,就是预先创建好一组线程。
它们一直活着,等着接活。
任务来了,放入队列。
空闲线程抢任务,做完再等下一个。
这就好比餐厅雇好了服务员,不用每来一个客人就招一个新员工。
在Linux下,可以用libevent或boost(C++)实现。
纯C语言的话,自己写一个也不难。
核心就是一个任务队列,加上一组工作线程。
注意,线程池的大小不是越大越好。
通常设置为CPU核心数的1到2倍比较合理。
太多线程会导致上下文切换频繁,反而降低性能。
太少线程,CPU利用率上不去。
这个平衡点,需要压测来找。
调试:多线程的噩梦与乐趣
写多线程代码,最怕的是Bug复现。
有时候程序跑得好好的,换个环境就崩了。
或者跑了一万次,才出现一次错误。
这就是并发Bug的狡猾之处。
时间竞争(Race Condition)导致的错误,极难捕捉。
推荐使用ThreadSanitizer(TSan)。
GCC和Clang都支持这个工具。
编译时加上-fsanitize=thread选项。
运行时,TSan会实时监控数据竞争。
一旦发现两个线程同时访问同一内存,且至少一个是写操作。
它就会立刻报警,并打印堆栈信息。
这比你自己查代码要快得多。
另外,日志打印也要小心。
别在临界区里打印日志,这会拖慢速度,甚至导致死锁。
最好把日志内容先攒起来,出临界区后再打印。
或者使用线程安全的日志库。
调试多线程,心态要稳。
别指望一次写对,多测试,多复盘。
每次解决一个并发Bug,都是经验的积累。
写在最后
C语言多线程编程,确实有门槛。
但它也是理解计算机底层并发机制的最佳途径。
没有黑盒,没有抽象,只有赤裸裸的内存和指令。
掌握了Pthread,再去学Java的Concurrent包,或者Go的Goroutine,都会觉得轻松很多。
因为底层逻辑是相通的。
锁、信号量、原子性,这些概念无处不在。
多写代码,多踩坑,多调试。
当你不再害怕“死锁”和“竞态”时,你就真的入门了。
别忘了,性能优化无止境。
从线程池到无锁队列,每一步都有提升空间。
保持好奇,保持谨慎,享受这段旅程。