写并发代码的时候,听得最多的劝告就是"多线程记得加锁"。但锁只是手段,很多人其实没搞明白:同步到底在防什么?为啥加了锁还会出问题?无锁又是什么玩法?
这篇把线程、同步、原子操作、CAS、内存屏障、伪共享这些概念串起来讲一遍,代码是 C++ 的,但思路语言无关,Java、Go 里同样适用。
先说线程是个啥
线程本质是一个"执行上下文":它有自己独立的执行流、调用栈、错误码、私有数据。Linux 内核里,线程和进程都叫任务(Task),一个执行流就是一条指令序列。
这里有个容易混淆的点:逻辑线程和硬件线程不是一回事。你代码里 new 出来的线程是逻辑线程,它描述的是"做什么、怎么做";而真正干活的是硬件线程——一个 CPU 核心可以支持超线程,1 个核跑 2 个硬件线程,复用一部分执行单元。
线程切换是有代价的,要保存恢复上下文、刷新缓存,切多了反而比串行还慢。所以线程不是越多越好,这也是为什么现在大家都在聊"线程池大小怎么定"。
同步防的是竞态
多线程为啥要同步?因为线程之间会共享内存。举个最常见的例子:消息队列,I/O 线程往里放消息,Work 线程往外取,两边同时操作同一个队列,如果不做任何保护,数据就乱了——你取的时候别人正在写,读到一半的状态。
![]()
最朴素的做法就是加互斥锁:操作前 lock,操作完 unlock,保证同一时刻只有一个线程碰队列。互斥锁之外,还有读写锁(读多写少场景)、条件变量(等条件满足再干活)、信号量(控制并发个数)、自旋锁(临界区极短时用,省得线程切换)——一套"同步原语"家族,各有各的适用场景。
从锁到无锁
锁用多了,性能是个问题,而且一不小心就死锁。于是有了更底层的玩法:原子操作。原子操作由 CPU 指令直接保证,要么完整执行要么不执行,中间不会被插一脚。
原子操作之上最经典的是 CAS(Compare And Swap):先比较内存里的值是不是预期的,是就交换成新值,不是就说明被别人改过了,重试。很多"无锁队列""无锁计数器"就是拿 CAS 拼出来的。
无锁(lock-free)的精确定义很多人搞错:它不是说不用锁,而是说任何一个线程被卡住,其他线程还能继续往前走,系统整体吞吐不依赖某个线程的运气。
![]()
两个看不见的坑
光知道原子操作还不够,还有两个藏在硬件层面的坑。
一个是乱序执行。编译器和 CPU 为了性能会重排指令,你写的代码顺序,实际执行时可能是另一回事。多线程场景下,这种重排会导致另一个线程看到"不该看到的状态"。解决办法就是内存屏障——告诉 CPU:这里不许乱序,前后的操作必须按顺序来。
另一个是伪共享(False Sharing),这个坑特别隐蔽。CPU 缓存是按 Cache Line 加载的,一般是 64 字节。假设两个线程各写各的变量,但这两个变量恰好挤在同一个 Cache Line 里——表面上互不干扰,实际上两个核的缓存为了保持一致性来回同步,性能直接崩掉。
原文里有个实验特别直观:两个线程往共享内存的不同位置写数据,结果耗时 3.4 秒;把每个线程的数据 padding 到 128 字节、让它们落进不同的 Cache Line 之后,耗时直接降到 0.06 秒——差了 50 多倍,代码逻辑一点没变,只是内存布局变了。
![]()
我的建议
说了这么多,最实用的一条反而是:最快的同步就是不同步。能别共享就别共享——用副本、用不可变数据、用队列把并发变成串行处理,都比在共享内存上跳舞省心。
设计优先级大概是:先想能不能无共享,再想能不能无锁,最后才轮到加锁。真到了要加锁那一步,也尽量缩小临界区,别在锁里干重活。
你在项目里踩过伪共享或者死锁的坑吗?评论区聊聊,说不定能帮到正在调并发性能的兄弟。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.