单线程系统中是否仍需执行数据同步操作?
单线程系统中是否仍需执行数据同步操作?
这问题真的戳中了很多开发者的认知盲区——我当年跟同事调试嵌入式单核心设备的时候,就因为忽略了这点踩过坑,今天就把理清楚的干货跟你唠唠。
首先得把前提掰扯明白:我们说的是**只有一个物理执行线程(无多核、无超线程),但OS靠时间片切换/系统调用挂起,实现多个逻辑线程(用户态线程或内核线程)**的场景,比如很多老的嵌入式设备、早期的单核心PC。
先给结论:绝大多数情况下,你还是需要用同步原语(原子操作、互斥锁、内存屏障这些)
别着急反驳,咱分场景拆开来聊:
1. 同一进程内的多逻辑线程:同步是刚需,跟多核无关
很多人觉得“单核心同一时间只跑一个线程,数据肯定不会乱”,但忽略了三个致命问题:
- 指令/编译器重排序:不管是编译器为了优化打乱代码顺序,还是CPU的乱序执行(哪怕单核心也会做指令级并行),都会导致逻辑上的“先写后标记”变成实际执行的“先标记后写”。举个例子:
线程A要给线程B发数据:
编译器可能为了优化,把shared_data = 100; data_ready = true;data_ready = true重排到shared_data = 100前面。这时候如果OS刚好在这两句之间把线程A挂起,切换到线程B,线程B看到data_ready=true,读shared_data就会拿到旧值,直接炸锅。 - 非原子的数据读写:比如在32位CPU上写一个64位的
long long,CPU会拆成两次32位的写操作。如果线程切换刚好发生在两次写之间,另一个线程读到的就是“半更新”的撕裂数据——前32位是新值,后32位是旧值,完全不符合预期。 - CPU写缓冲的延迟:单核心CPU也有写缓冲,用来隐藏内存读写的延迟。如果线程A写完数据后没做内存屏障,写操作可能还停在缓冲里没刷到缓存/物理内存,OS切换到线程B后,线程B读的就是旧数据。
2. 不同进程间的共享内存:同步同样不能少
不同进程的虚拟地址空间是隔离的,但如果用共享内存(比如mmap的共享页)共享数据,哪怕单核心也有问题:
- 进程切换时,OS会切换页表,虽然单核心的缓存是按物理地址索引的,但TLB(虚拟地址转物理地址的缓存)会被刷新或切换ASID。这时候如果线程A的写操作还在CPU写缓冲里,没同步到物理内存,进程B读共享内存时就会拿到旧值。
- 本质上,不同进程共享数据的问题和同一进程多线程是一样的:还是要面对重排序、写缓冲、非原子操作的坑,同步原语照样得用。
架构和OS会影响什么?
不同架构的内存模型确实会影响同步的细节,但不管是x86、ARM还是RISC-V,单核心场景下同步都不是多余的:
- x86是强内存模型,默认的读写操作不会乱序(除了写读重排),但编译器的重排序还是存在,而且非原子操作的撕裂问题依然存在。
- ARM、RISC-V是弱内存模型,CPU本身就会做更多的指令重排,哪怕单核心,也必须用
atomic的内存序(比如acquire/release)来保证执行顺序。 - 对OS来说,通用OS(Linux、Windows、macOS)不会帮你处理用户态的同步问题——线程切换时只会保存/恢复CPU上下文,不会主动做内存屏障或数据同步。只有一些极端定制的嵌入式OS,可能会在切换时加屏障,但这属于特例,不能依赖。
实践建议:别瞎屏蔽同步原语
很多人想搞个#ifndef NO_MULTITHREADING把同步代码屏蔽掉,省点开销,这真的是捡芝麻丢西瓜:
- 现代同步原语的开销极小:比如x86上的
std::atomic操作,大部分情况下就是一条普通指令,加内存屏障也只是一两条指令,对单核心系统的性能影响可以忽略。 - 屏蔽同步后,代码会变得极度脆弱:哪天把代码移植到多核系统,或者换个弱内存模型的单核心架构,分分钟出bug,而且这种bug极难调试(时序问题,复现率极低)。
- 几乎没有单核心系统会把同步指令noop掉:原子指令、内存屏障是CPU的基础指令集,哪怕单核心也需要它们来处理中断、系统调用的同步,OS不会把这些指令废掉。
极端特例:什么时候可以不用同步?
只有当你能同时满足所有以下条件时,才可以省掉同步:
- 所有共享数据的读写都是原子操作(比如32位整数在32位CPU上的读写);
- 编译器禁用了所有优化(
-O0),保证代码严格按你写的顺序执行; - 逻辑线程不会因为任何原因被挂起(比如没有系统调用、没有中断触发线程切换)——但你既然用非阻塞IO,就肯定会有系统调用导致的挂起,所以这个条件几乎不可能满足。
总结一下:哪怕是单核心系统,只要你用了多逻辑线程(尤其是涉及非阻塞IO的场景),同步原语该加还是得加,别抱着侥幸心理省那点代码或者开销,不然踩坑的时候哭都来不及!




