一、问题
最近跟同事一起讨论多线程读写数据需要加锁的事,他问了个问题,多线程读写基本数据类型也需要加锁吗?只是设置一个指针,是不是只会出现读到旧值的问题,而不会崩溃?
要回答这个问题,其实是要看往内存写基本数据类型是不是原子的,也即另一线程会不会看到设置到一半的数据。这就需要我们详细分析一下写内存时究竟发生了哪些事,及其对应的不加锁时可能出现的多线程风险。
二、流程分析
代码中给一个变量设置新值,首先会由编译器将这行代码转成汇编指令(STORE),运行时CPU执行这些访存指令,将数据设置给相应的内存地址。
1)编译器规范
所以第一步是编译器对这种情况的处理:
- C++标准中规定:多线程访问同一个非atomic对象,且至少有一个线程是写操作,是未定义行为。
所以从规范角度看,多线程场景只要有写操作,必须加锁(或使用原子变量)才能保证安全性。
如果未加锁,编译器有可能会将一个变量写操作转成多条指令(如将64位的数据操作转成两个32位的store指令),如果是转成两条指令,那么多线程情况下就可能会出现另一线程只看到更改了一半数据的问题。
2)访存及其多线程风险
接下来是CPU实际执行内存访问,下面拆解一下其具体流程:
CPU访问内存(虚拟地址)的步骤:
- 虚拟地址转物理地址(可与2.1并行)
- 1.1 查询TLB:虚拟地址中除了页内地址的部分,直接查询TLB看是否缓存有其物理地址
- 如果有,则直接返回物理地址
- 如果没有,则需要查页表:页表存在内存中,所以查页表意味着增加了一次虚拟地址的访问
- 查L1 cache(VIPT:Virtually Indexed Physically Tagged)
- 2.1 将虚拟地址中的一部分(页内偏移的高位)作为index找到
cache line组
- 假设cache line大小为64byte,cache大小为32K,组相联路数为8,则cache line组数 = 32K/(64 * 8) = 64组
- cacheline 64byte,占地址位数的6位:[0,5]
- 组数64组,占地址位数6位:[6,11]
- 如果页大小为4K,则页面偏移一共12位,则地址位数中的[0,11]都是页内偏移,也即虚拟地址与物理地址的[0,11]位是相同的,所以可以用虚拟地址的[6,11]位来直接查找cache组的index,而不用等到步骤1.返回物理地址再开始查找
- 2.2 拿物理地址(步骤1.)中的高位与上一步找到的组中各
cache line
中的tag进行比较,找到对应的cache line
- 如果存在,则直接返回
cache line
数据给CPU Core - 如果不存在,则说明cache中无缓存,需要等待下级缓存或memory返回数据
- 如果存在,则直接返回
- 查L2 cache(PIPT:Physiccally Indexed Physiccally Tagged)
- 3.1 物理地址的一部分作为index找到
cache line组
- 3.2 物理地址中的高位与组中各
cache line
的tag进行比较,找到相应的cache line
- 如果存在,则返回
cache line
数据 - 如果不存在,则查询memory
- 如果存在,则返回
- 查物理内存
- 4.1 CPU将物理地址发送到FSB(Front Side Bus),通过北桥发送给内存控制器,得到内存数据
- 4.2 填充各级cache
- 4.3 返回数据给CPU Core
- 在写回(WriteBack)模式下,新写入的数据会被更新到当前CPU core的L1 cache line中,而不直接写进内存,后续必要时再将cache line写入内存。
访存过程中的多线程风险:
因为cache中数据更新的粒度是cache line
,如果被访问数据跨越了多个cache line
,则可能导致另一个线程看到更新了一半的数据
- 基本类型数据:如果是默认对齐的情况(没有编译期指定alignment或pack),编译器能保证它不会跨越cache line,不跨cache line的操作是原子的
- 非基本类型数据(如struct),有可能出现一半字段在cache line 1,一半在cache line 2的情况,如果业务逻辑中依赖了struct中两个字段要一致,则可能出现预期外数据