Java Memory Model Pragmatics (transcript) 阅读笔记

博客链接:

Java 内存模型语用学 (transcript) --- Java Memory Model Pragmatics (transcript)

博客前言

该博客是给想要了解一些底层硬核并发编程的人的,如果只是想要写一些并发程序,可以去读JCIP。

Intro  介绍

Language specs通过“抽象机器去执行一段程序“得到的行为来描述语义,所以可以说language spec = abstract machine spec。

所以我们研究一下抽象机器怎么执行的。

而存储是抽象机器的很大一部分组成(机器无非就是指令+内存),而存储的很大一部分组成则是,我们在程序中的特定读取应该会得出什么结果(我们使用内存的本质原因就是要读取,不可能写入了永远不读嘛)。

在顺序程序(单线程),我们上一次写的什么,那肯定就读的什么,看起来好像没什么东西。“然而,即使在顺序情况下,记忆模型也很重要(尽管它们经常巧妙地伪装在评估顺序的概念中)。”

C语言,两个分号之间,实现可以对程序做任何事情,所以对于两个++i的增量,程序不知道会读到什么东西。

int i = 5;
i = (++i + ++i);

上面这个东西出现的12,不能简单用执行顺序未知来描述,因为12理论上不可能出现,12出现的原因是两次++i都没看到对方的数值。如果保持了顺序性,怎么都应该是13。

 

一门语言的实现,无非就是 1. 编译成可执行文件然后执行(c/c++) 2.解释器 (python)

有人说解释器就可以免疫内存模型的问题,实则不然。作者给出的简单的例子是,如果解释器在运行的过程中,缓存了volatile的值,那就完了。

程序依然需要聪明的开发者的原因是,我们没有一个极端聪明的编译器。在编译工程领域,有一些问题是无法确定的,甚至没有理论解。为了让实用的编译器成为可能,只能往语言里面假如不便。硬件同理,很多算法无法在硬件层面成立。

 

Part I. Access Atomicity  第一部分 访问原子性

What Do We Want

我们想要access atomicity guarantee。A线程在写入,B线程去读,我们希望,B读到的是:A写入的值 or 初始值。如果没有原子性,A可能写了一半,比如刚写入前一半的字节,那B就去读,读了一个很奇怪的值。

What Do We Have

那其实有点奇怪,为什么程序可能连这个都做不到呢?我们必须要有足够宽度的

比如32位的x86系统,一个内存就是32位,也就是4字节。如果我们有一个8字节长的byte数据,我们的原子写入和读取就完蛋了,要分两次,那就没有原子性了(跨越缓存行的操作通常就没有原子性了)。甚至就算有够长的操作,也不一定好使。

大多数平台只提供了最高32位的原子操作,那64位的操作就需要妥协了。我们可以悲观地获取锁,但是性能就不行。因此我们提供了一个逃生舱口:用户将 volatile 放在他们需要原子性的地方,VM 和硬件一起工作来保留它,无论成本是多少。

而宽度够了一不一定行,比如数据存在了两行缓存行,那就是两次读取事务,又没有原子性了。VM必须要帮我们对齐数据。

slide给的例子,是64位机器,8bytes一行,我们给offset12+4 ==> 16,这样long就会在下一行刚好占满整行。不对齐也行,只要我们不要求原子性,但是这样很不好。

Quiz的意思是,volatile+VM很稳健,能够帮我们搞定原子性,所以不是全0就是全1,不可能只有一半是1一半是0.

Value Types and C/C++  值类型和 C/C++

C++这里把这104字节绑定成了一个struct,然后加上atomic类型,没有机器指令可以帮忙,所以需要设计者自己利用CAS和lock来设计出来原子性的操作。

C++ allows separate compilation: now the linker is tasked with the job of figuring out what locks/CAS-guards are used by this particular std::atomic

最后一段指出C++分离编译使得std::atomic的原子性实现更复杂,链接器需要确保不同源文件对同一原子对象的同步机制一致。如果线程运行由不同编译器生成的代码,原子性可能因实现差异而受损,带来潜在的未定义行为。

JMM Updates

在2014年,是否还需要重新考虑64-bit exception?

意思就是,在32位平台上面没有天然支持long和double的指令,但是我们又希望代码可移植,所以就可以修改JVM无条件发出原子指令,让我们的代码在32位平台上面也能默认支持原子性,这样就不用volatile使得程序变慢了。
表格1显示,volatile write为写屏障支付了巨额的时间费用。
32位平台的表格显示了,需要注入特殊的指令来保证原子性。
测试设计中写入long字段是为了避免编译器优化,确保原子性测试的准确性,这是无奈之举。

Part II. Word Tearing

What Do We Want

如果我们的硬件无法访问一个不同的数组元素,它将被迫读取多个元素(这样的话就没有同步了,可能导致一个线程的写入被覆盖掉)

What Do We Have

我们的硬件支持肯定没有1bit的读写,要怎么样处理boolean数组的操作呢?

大多数硬件是8bits往上,一个合理的语言不应该提供比硬件能支持的最小字节操作还要小的类型,java就比较合理,防止word tearing,所以我们只需要考虑boolean。

 问题二: Of course, you also need to tame any compiler optimizations which may buffer reads and writes along with the adjacent data.

 其实数据类型的字节只是最小字节数,比如long最小8字节,但是128字节也是可以的。

BitSet会出现字撕裂的问题。

Layout Control and C/C++ 

C++提供了类似以下的操作

struct Example {
    unsigned a:7; // 7位无符号整数
    unsigned b:3; // 3位无符号整数
};

纯靠自己设计来避免字撕裂。

JMM Updates

Part III: SC-DRF

What Do We Want 

我们从整体研究一下程序的读取,我们希望程序在全局至少能看起来是有一个顺序执行的,即sequential consistency

sequential consistency应当与linearizability区分,前者不需要一个真的总顺序的执行。有一个操作的集合,我们对这个操作集合进行执行,其结果是难以跟给定的结果分辨开来的。

简单解释一下,就是说,有一个并发的程序执行出来了一个结果,比如说

ConcurrentQueue q = new ConcurrentQueue(); // 初始空

// 线程 A
q.enq(x);      // t0 到 t1,先完成
y = q.deq();   // t2 到 t3,返回 y?

// 线程 B
while (q.size() == 0) { /* wait */ } // t4 到 t5,看到非空
q.enq(y);      // t6 到 t7

明显B会在A之后放进去y,那么队列里面应该是x->y,A怎么可能返回y呢?这在SC中是可能的。

What Do We Have

可惜SC不让单线程程序重排序自己内部的操作。问题是编译器(以及硬件)会做大量的重排序来让我们的程序跑得更快,这些操作其实非常好,为了SC而在程序中滥用内存屏障禁止重排序就会让这些操作变少很多然后程序就很慢。

 程序如果有races,就会出现未知的结果。

所以SC有些难以做到了,需要妥协削弱,需要一个更弱的模型。

Java Memory Model

PO是全序的(在一个线程内,所有操作按照代码的书写顺序形成一个明确的先后关系,任意两个action都被PO序包含

PO也是有可能被重排序,也没有顺序保障,只不过是提供了原程序和执行的结果的一条链接。对action重排序就会产出新的execution,我们再去分析是否违反JMM。

给定了动作和执行,我们可以产出无数的执行流程。

PO可以过滤掉线程内的不相关的执行流程,提供了一种线程内的一致性规则。
JMM并不是一个构建型的模型,而是不断地过滤。

Synchronization Order

弱模型不会对全部指令排序,只会对某一些排序,这些会被排序的放进Synchronization Actions。


SO是全序,每一个线程看到的SA都是同一个顺序。

 SO-PO体现的是,所有不满足PO的都被砍掉了,所以暗示SO要遵循PO,而SO一致性告诉我们,我们在切换线程的过程中,之前的效果都被带上了。

Synchronization Actions are sequentially consistent.

IRIW: 

为了满足一致性,需要y=1再到x=1,又需要x=1再到y=1,显然矛盾,不符合total order

需要给非SA的指令一个比较弱的关系,不能全是SA,也不能全都不是SA

Happens-Before

引入子序,SW,偏序

注意我们有意没有让x也是volatile。

SW:SO的子序,给具体的读写,锁和解锁添加限制

write(g,1)---read(g):0,这个虽然是SO关系,但是不是SW,因为没有读到1,一定要一样的,读到了前面写的才是SW

 

1. 什么是传递闭包?

  • 定义:传递闭包是针对一个关系(如顺序关系)的扩展,使得如果存在 A→B 和 B→C 的关系,那么 A→C 也包含在关系中。
  • 通俗理解:如果 A 在 B 之前,B 在 C 之前,那么传递闭包确保 A 也在 C 之前。它把所有“间接”关系显式化,形成一个完整的顺序网络。
  • 数学上,对于关系 R,传递闭包 R* 是最小的传递关系,包含 R 的所有直接和间接连接。

 But if we have a write unordered in HB with respect to a given read, then we also can see that (racy) write. Let’s define it more rigorously.

如果出现了竞争性写,那么读取可能看得到这个写。

这个公式,前半部分,对读取r之后才写入取反,其实就是暗示,写入之后再读和竞争读都是可以的。后半部分,对存在某个写入,使得W之后写入之后再读取反,意思就是不存在这个写入,只能读到最近的。

SC-DRF说明了一件事,只要没有races(两个以上线程碰一个共享变量,且有一个action是写入),我们的程序就是顺序一致(SC)的了。

注意HB搭桥基于SW,而SW则基于同步,比如volatile。

同步关系(synchronizes-with, SW)的起点称为“释放(release)”,终点称为“获取(acquire)”。HB 包含 SW,因此,跨越不同线程的 HB 也始于“释放”,止于“获取”。
使用volatile T val是为了提供一个获取口,连成通道,才能让getter看到修改的值
 

JMM Interpretation: Roach Motel

因为race能读到1,所以移上去也是1,符合结果。通过这样的操作,我们让锁变得粗化

测试发现,共享变量的影响甚至比volatile本身要大。

Part IV: Out of Thin Air

b一开始是0,编译器投机把它先变成了42,最后再变回了0,那就很合理了,其实是不对的,这个给其他线程带来了影响。复杂的机制规避了。

Part V: Finals

 

posted @ 2025-04-14 08:23  映空城  阅读(11)  评论(0)    收藏  举报