Project RC

一种标准阿西的设计与实现。

对开源的一些感悟

创建于
分类:Misc
标签:开源Open Source软件开发CQHTTP

通过 CQHTTP(最初的全称叫 CoolQ HTTP API),我经历的不只是一个项目的开发、迭代和流行,而是一整个以 CQHTTP 为核心的开源社区的形成,这里面包括各语言 SDK、开发框架、具体的机器人应用、web 控制面板、API 兼容的替代实现、CoolQ 关闭服务之后的替代 runtime、各类教程和文档等。

有时候我觉得这个过程和 Linus Torvalds 上传 Linux 初版代码之后逐渐形成 Linux 社区没什么本质的不同,只是规模小了几个数量级、场景更加小众。

在这个社区的形成过程中,很重要的一点是,CQHTTP 和大部分周边项目都是开源的,开源的关键在于“开放源代码”(open source),而不只是“公开源代码”(public source)。“开放”的意思是说,首先,社区是开放的,任何人都可以参与到社区里来,开发各式各样的周边项目,其次,这些项目本身也是开放的,从项目的一开始就以开源的方式进行,作者在 GitHub 上公开 push 代码,同时也随时可接受别人的 pull request。与之相对的,是另一种我想称之为“假开源”的形式,在项目封闭开发完成后,去掉所有 Git 历史,创建新的第一个 commit,内容写做“release 1.0.0”,然后发布到代码托管网站,接着可能在 issue 里面回答一些问题,代码仍然封闭开发,到下一版完成后,再去掉 Git 历史,覆盖到上一版代码中,提交第二个 commit,写做“release 1.1.0”。这后一种“开源”,只是把源代码公开了,而不是真正的开放。

另一个很重要的点,是所有项目的开发者,都是自发地来参与开发,而不是为了钱或是被迫的。每个人都是出于自身的兴趣,选择从头开发或者参与社区中的已有项目,没有人需要被安排“你这周做什么,下个月做什么”,也没有人期待从项目中获得金钱收益。大家主动从社区中发现需要修复的 bug、需要补充的功能,而不是被动地做自己不愿意做的事。如果你要问这些开发者为什么作业那么多、上课那么忙,还要熬夜写这些代码,大部分会回答“因为好玩”。

从初中生、高中生到大学生,甚至是工作后半路转行做程序员的人们,有很多人在使用 CQHTTP 社区项目的过程中入门了编程,无论是 C++、Python 或是其它。也有很多人通过开发 CQHTTP 社区项目获得了自己的用户群体、GitHub 星星、更丰富的编程经验和更广的人脉,并以此找到了更好的学习和工作机会,包括我自己。这常常让我感慨万分,让我感到自己曾经做的事情并不是简单的玩具,虽然它的影响范围很小,但它是有意义的,它帮助到了许多人,也或许间接地微弱地帮助到了中国的软件行业。

我想我会一直坚持我所认同的开源理想主义,坚持我的初心。

当然了,开源的开发形式和理想主义并不是 CQHTTP 本身成功的根本原因,它成功的根本原因是解决了 CoolQ 难以用多种编程语言开发的痛点。如果当时我选择闭源,它可能仍然会成功。但如果真的闭源,可能就很难形成后来那么开放的社区了。所以,要想成功,解决痛点是必须的,而解决痛点之后以什么形式呈现,就是理想主义和现实主义的分野了。

对开源的一些感悟

Rust FFI 调用时传递 trait object 作为参数的问题

创建于
分类:Dev
标签:RustFFICC++Trait Object虚函数表

最近实验室一个同学在使用 Rust 和 C 互调用时,发现当把 trait object 指针传入一个接受 void * 的 C 函数后,C 再次使用这个指针作为参数调用 Rust 的函数,会发生 segmentation fault,交流和搜索之后找到了原因和解决方案,记录如下。

Foreign Language Interface(FFI)

众所周知,Rust 和 C 可以相互调用对方导出的函数,这种调用接口称为 FFI。

举一个极简的例子,来演示 Rust 和 C 的互调用:

// src/boring.c

#include <stdio.h>

extern void call_rust_with_foo(void *foo);

void call_c_with_foo(void *foo) {
    printf("Hello from C!\n");
    call_rust_with_foo(foo);
}
// src/main.rs

use std::os::raw::c_void;

pub struct Foo {}

impl Foo {
    fn foo(&self) {
        println!("foo!");
    }
}

#[link(name = "boring", kind = "static")]
extern "C" {
    fn call_c_with_foo(foo: *const c_void);
}

#[no_mangle]
pub extern "C" fn call_rust_with_foo(foo: *const Foo) {
    println!("Hello from Rust!");
    let foo = unsafe { &*(foo) };
    foo.foo();
}

fn main() {
    let foo_impl = Foo {};
    let foo = &foo_impl;
    unsafe {
        call_c_with_foo(foo as *const Foo as *const c_void);
    }
}
// build.rs

use cc;

fn main() {
    cc::Build::new().file("src/boring.c").compile("libboring.a");
}

编译运行会输出:

Hello from C!
Hello from Rust!
foo!

出错场景

在上面的例子中,如果因为一些需求,要把 Foo 改成 trait,struct FooImpl 实现 trait Foo,此时试图在 FFI 函数参数中传递 *const dyn Foo,就会出现 segmentation fault。

修改后 src/main.rs 文件如下:

// src/main.rs

use std::os::raw::c_void;

pub trait Foo {
    fn foo(&self);
}

pub struct FooImpl {}

impl Foo for FooImpl {
    fn foo(&self) {
        println!("foo!");
    }
}

#[link(name = "boring", kind = "static")]
extern "C" {
    fn call_c_with_foo(foo: *const c_void);
}

#[no_mangle]
pub extern "C" fn call_rust_with_foo(foo: *const dyn Foo) {
    println!("Hello from Rust!");
    let foo = unsafe { &*(foo) };
    foo.foo();
}

fn main() {
    let foo_impl = FooImpl {};
    let foo = &foo_impl as &dyn Foo;
    unsafe {
        call_c_with_foo(foo as *const dyn Foo as *const c_void);
    }
}

运行输出:

Hello from C!
Hello from Rust!
[1]    68850 segmentation fault  ./target/debug/trait-object-ffi

胖指针和瘦指针

经过一些搜索后明白出错的原因与胖瘦指针有关。Rust 中的 trait object 是胖指针(fat pointer),在 64 位机器上,占据 16 字节,是瘦指针(thin pointer)的两倍,实际也就是由两个瘦指针构成。

通过 std::mem::size_of 可以验证这一点:

fn main() {
    println!("{}", std::mem::size_of::<&FooImpl>()); // 输出 8
    println!("{}", std::mem::size_of::<&dyn Foo>()); // 输出 16
}

为什么会有这个区别呢,是因为当把 &foo_impl 转成 trait object 时,它丢失了原来的具体类型信息,这时候要调用 FooImpl::foo 函数,需要走虚函数表查询函数地址,这个过程叫做 动态分派(dynamic dispatch)。因此,&dyn Foo 的第一个 8 字节指向 FooImpl 对象,第二个 8 字节指向 FooImpl 的虚函数表。

有趣的是,C++ 中实现 dynamic dispatch 时,是把虚函数表指针放在对象的开头(这篇文章 中有过相关讨论),所以 C++ 中不需要胖指针,代价是调用虚函数时需要多一次 dereference。

回到问题,由于 &dyn Foo 乃至 *const dyn Foo 是胖指针,那么转成 *const c_void 传给 call_c_with_foo 的时候就已经丢失了第二个 8 字节的虚函数表指针,由 C 代码再传回 call_rust_with_foo,虚函数表指针就是一个野指针了。

解决方案 1:两层指针

要解决问题,最简单的方案是把胖指针放在一个变量(或者 Box)里,再取这个变量的地址传给 C,也就是通过两层指针来传递。

这种方案不需要修改上面的 C 代码,只需修改 src/main.rs

// src/main.rs
// ...

#[no_mangle]
pub extern "C" fn call_rust_with_foo(foo: *const *const dyn Foo) {
    println!("Hello from Rust!");
    let foo = unsafe { &**(foo) };
    foo.foo();
}

fn main() {
    let foo_impl = FooImpl {};
    let foo = &foo_impl as &dyn Foo;
    let foo_raw = foo as *const dyn Foo;
    unsafe {
        call_c_with_foo(&foo_raw as *const *const dyn Foo as *const c_void);
    }
}

解决方案 2:通过 struct 传胖指针

另一种解决方案是直接把胖指针转成某个 struct,然后按 struct 传给 C。

第一种写法

Rust 标准库中曾经有过一个 unstable 的 struct 叫 std::raw::TraitObject 可以用来实现这个解法,虽然它已经被 deprecated 了(rust-lang/rust#84207rust-lang/rust#86833),我们仍然可以手动定义,类似下面这样:

#[repr(C)]
pub struct TraitObject {
    data: *const c_void,
    vtable: *const c_void,
}

于是对代码修改如下:

// src/boring.c

#include <stdio.h>

struct TraitObject {
    void *data;
    void *vtable;
};

extern void call_rust_with_foo(struct TraitObject foo);

void call_c_with_foo(struct TraitObject foo) {
    printf("Hello from C!\n");
    call_rust_with_foo(foo);
}
// src/main.rs
// ...

#[repr(C)]
pub struct TraitObject {
    data: *const c_void,
    vtable: *const c_void,
}

#[link(name = "boring", kind = "static")]
extern "C" {
    fn call_c_with_foo(foo: TraitObject);
}

#[no_mangle]
pub extern "C" fn call_rust_with_foo(foo: TraitObject) {
    println!("Hello from Rust!");
    let foo: &dyn Foo = unsafe { std::mem::transmute(foo) };
    foo.foo();
}

fn main() {
    let foo_impl = FooImpl {};
    let foo = &foo_impl as &dyn Foo;
    unsafe {
        call_c_with_foo(std::mem::transmute(foo));
    }
}

第二种写法

std::raw::TraitObject deprecated 之后,标准库引入了新的接口(rust-lang/rfcs#2580)来实现类似的功能。于是上面的 Rust 代码也可以写成这样:

// src/main.rs

#![feature(ptr_metadata)]

use std::ptr::{DynMetadata, Pointee};

// ...

#[repr(C)]
pub struct TraitObject<T: ?Sized + Pointee<Metadata = DynMetadata<T>>> {
    data: *const (),
    vtable: DynMetadata<T>,
}

#[link(name = "boring", kind = "static")]
extern "C" {
    fn call_c_with_foo(foo: TraitObject<dyn Foo>);
}

#[no_mangle]
pub extern "C" fn call_rust_with_foo(foo: TraitObject<dyn Foo>) {
    println!("Hello from Rust!");
    let foo: &dyn Foo = unsafe { &*std::ptr::from_raw_parts(foo.data, foo.vtable) };
    foo.foo();
}

fn main() {
    let foo_impl = FooImpl {};
    let foo = &foo_impl as &dyn Foo;
    let foo_raw = foo as *const dyn Foo;
    let (data, vtable) = foo_raw.to_raw_parts();
    unsafe {
        call_c_with_foo(TraitObject { data, vtable });
    }
}

虽然这种写法编译器又会报 DynMetadata<T> 不是“FFI-safe”的警告,但由于 DynMetadata<T> 里面实际就是一个 VTable 的指针(是瘦指针),上面的代码是可以正确工作的。

参考资料

Rust FFI 调用时传递 trait object 作为参数的问题

关于静态博客生成器的一点感想

创建于
分类:Misc
标签:BlogOctopressBlogAVeriPressPurePress

从高中刚开始尝试搭建自己的博客,就是采用了静态博客的思路,即使用 Markdown 编写博文,然后用一个静态博客生成器将 Markdown 转换成 HTML,之后使用静态文件服务器部署。与之相对应的是 WordPress 的思路,即动态运行一个网站后端,在请求到来时从文件或数据库加载博文,生成 HTML 返回。

最开始使用的静态博客生成器是 Octopress,是基于 Jekyll 的,后者是 GitHub Pages 使用的默认静态博客生成器。用 Octopress 搭建博客的过程,也是不断学习许多新玩意的过程,比如 Git、常用的 Shell 命令、自定义域名等。

后来学了 Python,想着用这个语言做点什么有用的东西,于是写了自己的静态博客生成器 BlogA,再到后来的 VeriPress 和最新的 PurePress。这段过程在去年已经在我的 Telegram 频道 Channel RC 发过总结,摘录如下:

折腾静态博客生成器很久了,从刚开始学 Python 时候写的 BlogA,到后来重写的「功能看起来更丰富」的 VeriPress,基本满足了自己对一个博客生成器的需求。可是渐渐地,越来越觉得不太对,写 VeriPress 时试图让它变得可扩展,结果导致做了许多对我的需求没有帮助的抽象,代码变得看似解耦实则混乱,最终完全没有动力维护了。

于是前些时间决定再重来一次,对 VeriPress 做一个充分的精简,只保留自己真正需要的功能,尝试用最少的代码实现完美符合自己需求的博客生成器。经过好几次的忙里偷闲,终于写完了,命名为 PurePress。项目的核心 Python 代码只有两个文件,一个是 __init__.py,330 行,用来实现博客的 preview 功能,包括文章的加载、Flask 的路由等,另一个是 __main__.py,260 行,用来实现命令行工具和 build 功能。

PurePress 的一切都是基于我对静态博客生成器的使用习惯,不多不少,代码里没有一句废话(希望真的如此),实在是让人感到舒适。

经过一年的使用和小修小补,PurePress 仍然保持了初心,目前仅 672 行代码(包括空行和注释),只包含我真正需要的功能,即你在本博客网站及 GitHub 仓库 源码中能看到的功能。我想这些功能应该足够支撑大部分人写博客了。

到了最近,又渐渐感觉先前的博客主题 Light 太过花哨,博客的主要关注点应该是它的内容,而不是外观。于是又重新写了一个 PurePress 主题(参考了 这个博客),叫做 Minimal,采用了极简的样式,以凸显内容为目标,减少主题样式本身的喧兵夺主,最终效果还不错。

目前看来,PurePress 本身的功能和对主题的支持经过了一年多的考验还算不错。打算在之后有空的时候补一下文档,让更多对其有兴趣的人可以方便地用它搭建静态博客。

而自己的这个博客在换用 Minimal 主题之后,也将把重心放在输出更优质更有干货的内容上,而不只是让它看起来好看。

关于静态博客生成器的一点感想

std::function 作为参数的函数重载问题

创建于
分类:Dev
标签:C++std::function函数对象函数重载类型转换

今天写 C++ 遇到一个关于 std::function 的有趣问题,经过一些研究之后搞清楚了原因,记录如下。

问题

为了避免其它代码的干扰,把问题简化描述如下:

using namespace std;

void foo(const function<bool()> &f) {
  cout << "bool\n";
}

void foo(const function<void()> &f) {
  cout << "void\n";

  // 编译报错: 有多个重载函数 "foo" 实例与参数列表匹配
  foo([&]() -> bool { return true; });
}

void foo_user() {
  // 编译报错: 同上
  foo([&]() -> bool { return true; });

  // 不报错
  foo([&]() {});
}

为什么?

在某个 C++ 群里询问之后,有一个群友提醒说可以把编译报错的那个 lambda 表达式手动构造成 std::function<bool()> 来解决,于是修改成下面这样:

using namespace std;

void foo(const function<bool()> &f) {
  cout << "bool\n";
}

void foo(const function<void()> &f) {
  cout << "void\n";

  // 不报错
  foo(std::function<bool()>([&]() -> bool { return true; }));
}

void foo_user() {
  // 不报错
  foo(std::function<bool()>([&]() -> bool { return true; }));

  // 不报错
  foo([&]() {});
}

确实解决了无法找到合适的重载函数的问题。进而意识到,用 lambda 的时候会报错是因为 lambda 到 std::function 有一次类型转换,而 [&]() -> bool { return true; } 可能既可以转换为 std::function<bool()> 也可以转换为 std::function<void()>,从而产生了二义性。

群友后来又发现 std::function<bool()> 可以赋值给 std::function<void()>,于是猜测 std::function 可能不关心它所表示的函数的返回类型,但这仍然无法解释为什么 foo([&]() {}) 不报错。后来因为忙其它事情,群里也没有继续再讨论了。

空闲下来之后,继续研究了这个问题,把前面觉得可能的猜测都找到了定论。

为了方便解释,下面用 Cling 解释器来求值一些 type trait,以观察不同 std::function 实例之间的关系。

首先导入需要的头文件:

$ cling -std=c++17
[cling]$ #include <type_traits>
[cling]$ using namespace std;

然后用 std::is_convertible_v 来检查不同 std::function 之间是否能相互转换:

[cling]$ is_convertible_v<function<bool()>, function<void()>>
(const bool) true
[cling]$ is_convertible_v<function<void()>, function<bool()>>
(const bool) false
[cling]$ is_convertible_v<function<bool(int)>, function<bool()>>
(const bool) false
[cling]$ is_convertible_v<function<bool(int)>, function<void()>>
(const bool) false
[cling]$ is_convertible_v<function<bool(int)>, function<void(int)>>
(const bool) true
[cling]$ is_convertible_v<function<void(int)>, function<bool()>>
(const bool) false
[cling]$ is_convertible_v<function<void(int)>, function<void()>>
(const bool) false
[cling]$ is_convertible_v<function<void(int)>, function<bool(int)>>
(const bool) false

观察上面的结果可以发现,在参数类型相同的情况下,有返回类型的 std::function 可以转换为无返回类型的 std::function,反过来则不可以。

从而一开始的问题便可以解释通了:

  • [&]() -> bool { return true; } 既可以转换为 std::function<bool()> 也可以转换为 std::function<void()>,于是产生二义性;
  • [&]() {} 只能转换为 std::function<void()>,于是没有二义性;
  • 手动构造出的 std::function<bool()> 虽然也可以转换为 std::function<void()>,但由于有一个不需要类型转换的重载,于是也没有二义性。

为什么 std::function<bool()> 可以转换为 std::function<void()>

虽然一开始问题解决了,但是还是不明白为什么 std::function<bool()> 可以转换为 std::function<void()>,于是去找了 std::function 的实现(以 LLVM 的 libcxx 为例,代码相比 GNU libstdc++ 更清晰一些),节选如下:

template<class _Rp, class ..._ArgTypes>
class /* ... */ function<_Rp(_ArgTypes...)> /* : ... */ {
    // ...

    template <class _Fp, bool = _And<
        _IsNotSame<__uncvref_t<_Fp>, function>,
        __invokable<_Fp&, _ArgTypes...>
    >::value>
    struct __callable;

    template <class _Fp>
    struct __callable<_Fp, true> {
        // MARK 1
        static const bool value = is_same<void, _Rp>::value ||
            is_convertible<typename __invoke_of<_Fp&, _ArgTypes...>::type, _Rp>::value;
    };
    template <class _Fp>
    struct __callable<_Fp, false> {
        static const bool value = false;
    };

    template <class _Fp>
    using _EnableIfCallable = typename enable_if<__callable<_Fp>::value>::type;

    // ...

    // MARK 2
    template<class _Fp, class = _EnableIfCallable<_Fp>>
    function(_Fp);

    // ...
};

可以看到 MARK 2 处为满足 _EnableIfCallable<_Fp>_Fp 实现了一个构造函数,而满足 _EnableIfCallable<_Fp> 意味着 __callable<_Fp>::valuetrue。根据 MARK 1 处的偏特化,发现当 _Rp(也就是 std::function 的返回类型)为 void、或调用 _Fp 的返回值类型可以转换为 _Rp 时,__callable<_Fp>::valuetrue

也就是说,除了前面观察发现的结论——有返回类型的 std::function 可以转换为无返回类型的 std::function,标准库还允许有返回类型的 std::function 转换为返回类型可由前者的返回类型构造的 std::function。用上一节的方式验证如下:

[cling]$ is_convertible_v<function<int(int)>, function<long(int)>>
(const bool) true
[cling]$ struct A {};
[cling]$ struct B : A {};
[cling]$ is_convertible_v<function<B(int)>, function<A(int)>>
(const bool) true
[cling]$ is_convertible_v<function<A(int)>, function<B(int)>>
(const bool) false

看到这其实已经豁然开朗了,从逻辑上来说,一个有返回值的函数确实可以当作没有返回值的函数来调用,返回 T 的函数也可以当作返回 T 能转换到的类型的函数来调用,只需进行一次类型转换,非常合理。

为了确定这是 C++ 标准定义的行为而不是标准库实现的私货,去翻了 C++17 标准(因为前面的讨论都是在 C++17 标准上进行的,虽然新版本并没有变化),在 23.14.3 和 23.14.13.2 确实有相关表述,这里就不贴出了。

参考资料

std::function 作为参数的函数重载问题

ARMv8 内存系统学习笔记

创建于
分类:Note
标签:ARMARMv8内存模型内存序Memory OrderingCache

References

Cache coherency

Cacheability

Normal memory 可以设置为 cacheable 或 non-cacheable,可以按 inner 和 outer 分别设置。

Shareability

设置为 non-shareable 则该段内存只给一个特定的核使用,设置为 inner shareable 或 outer shareable 则可以被其它观测者访问(其它核、GPU、DMA 设备),inner 和 outer 的区别是要求 cache coherence 的范围,inner 观测者和 outer 观测者的划分是 implementation defined。

PoC & PoU

当 clean 或 invalidate cache 的时候,可以操作到特定的 cache 级别,具体地,可以到下面两个“点”:

Point of coherency(PoC):保证所有能访问内存的观测者(CPU 核、DSP、DMA 设备)能看到一个内存地址的同一份拷贝的“点”,一般是主存。

Point of unification(PoU):保证一个核的 icache、dcache、MMU(TLB)看到一个内存地址的同一份拷贝的“点”,例如 unified L2 cache 是下图中的核的 PoU,如果没有 L2 cache,则是主存。

当说“invalidate icache to PoU”的时候,是指 invalidate icache,使下次访问时从 L2 cache(PoU)读取。

PoU 的一个应用场景是:运行的时候修改自身代码之后,使用两步来刷新 cache,首先,clean dcache 到 PoU,然后 invalidate icache 到 PoU。

Memory consistency

ARMv8-A 采用弱内存模型,对 normal memory 的读写可能乱序执行,页表里可以配置为 non-reordering(可用于 device memory)。

Normal memory:RAM、Flash、ROM in physical memory,这些内存允许以弱内存序的方式访问,以提高性能。

单核单线程上连续的有依赖的 strldr 不会受到弱内存序的影响,比如:

str x0, [x2]
ldr x1, [x2]

Barriers

ISB

刷新当前 PE 的 pipeline,使该指令之后的指令需要重新从 cache 或内存读取,并且该指令之后的指令保证可以看到该指令之前的 context changing operation,具体地,包括修改 ASID、TLB 维护指令、修改任何系统寄存器。

DMB

保证所指定的 shareability domain 内的其它观测者在观测到 dmb 之后的数据访问之前观测到 dmb 之前的数据访问:

str x0, [x1]
dmb
str x2, [x3] // 如果观测者看到了这行 str,则一定也可以看到第 1 行 str

同时,dmb 还保证其后的所有数据访问指令能看到它之前的 dcache 或 unified cache 维护操作:

dc csw, x5
ldr x0, [x1] // 可能看不到 dcache clean
dmb ish
ldr x2, [x3] // 一定能看到 dcache clean

DSB

保证和 dmb 一样的内存序,但除了访存操作,还保证其它任何后续指令都能看到前面的数据访问的结果。

等待当前 PE 发起的所有 cache、TLB、分支预测维护操作对指定的 shareability domain 可见。

可用于在 sev 指令之前保证数据同步。

一个例子:

str x0, [x1] // update a translation table entry
dsb ishst // ensure write has completed
tlbi vae1is, x2 // invalidate the TLB entry for the entry that changes
dsb ish // ensure that TLB invalidation is complete
isb // synchronize context on this processor

DMB & DSB options

dmbdsb 可以通过 option 指定 barrier 约束的访存操作类型和 shareability domain:

option ordered accesses (before - after) shareability domain
oshld load - load, load - store outer shareable
oshst store - store outer shareable
osh any - any outer shareable
ishld load - load, load - store inner shareable
ishst store - store inner shareable
ish any - any inner shareable
nshld load - load, load - store non-shareable
nshst store - store non-shareable
nsh any - any non-shareable
ld load - load, load - store full system
st store - store full system
sy any - any full system

One-way barriers

  • Load-Acquire (LDAR): All loads and stores that are after an LDAR in program order, and that match the shareability domain of the target address, must be observed after the LDAR.
  • Store-Release (STLR): All loads and stores preceding an STLR that match the shareability domain of the target address must be observed before the STLR.
  • LDAXR
  • STLXR

Unlike the data barrier instructions, which take a qualifier to control which shareability domains see the effect of the barrier, the LDAR and STLR instructions use the attribute of the address accessed.

C++ & Rust memory order

Relaxed

Relaxed 原子操作只保证原子性,不保证同步语义。

// Thread 1:
r1 = y.load(std::memory_order_relaxed); // A
x.store(r1, std::memory_order_relaxed); // B

// Thread 2:
r2 = x.load(std::memory_order_relaxed); // C
y.store(42, std::memory_order_relaxed); // D

上面代码在 ARM 上编译后使用 strldr 指令,可能被乱序执行,有可能最终产生 r1 == r2 == 42 的结果,即 A 看到了 D,C 看到了 B。

典型的 relaxed ordering 的使用场景是简单地增加一个计数器,例如 std::shared_ptr 中的引用计数,只需要保证原子性,没有 memory order 的要求。

Release-acquire

Rel-acq 原子操作除了保证原子性,还保证使用 release 的 store 和使用 acquire 的 load 之间的同步,acquire 时必可以看到 release 之前的指令,release 时必看不到 acquire 之后的指令。

#include <thread>
#include <atomic>
#include <cassert>
#include <string>

std::atomic<std::string *> ptr;
int data;

void producer() {
    std::string *p = new std::string("Hello");
    data = 42;
    ptr.store(p, std::memory_order_release);
}

void consumer() {
    std::string *p2;
    while (!(p2 = ptr.load(std::memory_order_acquire)))
        ;
    assert(*p2 == "Hello"); // never fires
    assert(data == 42); // never fires
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join(); t2.join();
}

上面代码中,一旦 consumer 成功 load 到了 ptr 中的非空 string 指针,则它必可以看到 data = 42 这个写操作。

这段代码在 ARM 上会编译成使用 stlrldar,但其实 C++ 所定义的语义比 stlrldar 实际提供的要弱,C++ 只保证使用了 release 和 acquire 的两个线程间的同步。

典型的 rel-acq ordering 的使用场景是 mutex 或 spinlock,当释放锁的时候,释放之前的临界区的内存访问必须都保证对同时获取锁的观测者可见。

Release-consume

和 rel-acq 相似,但不保证 consume 之后的访存不会在 release 之前完成,只保证 consume 之后对 consume load 操作有依赖的指令不会被提前,也就是说 consume 之后不是临界区,而只是使用 release 之前访存的结果。

Note that currently (2/2015) no known production compilers track dependency chains: consume operations are lifted to acquire operations.

#include <thread>
#include <atomic>
#include <cassert>
#include <string>

std::atomic<std::string *> ptr;
int data;

void producer() {
    std::string *p = new std::string("Hello");
    data = 42;
    ptr.store(p, std::memory_order_release);
}

void consumer() {
    std::string *p2;
    while (!(p2 = ptr.load(std::memory_order_consume)))
        ;
    assert(*p2 == "Hello"); // never fires: *p2 carries dependency from ptr
    assert(data == 42); // may or may not fire: data does not carry dependency from ptr
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join(); t2.join();
}

上面代码中,由于 assert(data == 42) 不依赖 consume load 指令,因此有可能在 load 到非空指针之前执行,这时候不保证能看到 release store,也就不保证能看到 data = 42

Sequentially-consistent

Seq-cst ordering 和 rel-acq 保证相似的内存序,一个线程的 seq-cst load 如果看到了另一个线程的 seq-cst store,则必可以看到 store 之前的指令,并且 load 之后的指令不会被 store 之前的指令看到,同时,seq-cst 还保证每个线程看到的所有 seq-cst 指令有一个一致的 total order。

典型的使用场景是多个 producer 多个 consumer 的情况,保证多个 consumer 能看到 producer 操作的一致 total order。

#include <thread>
#include <atomic>
#include <cassert>

std::atomic<bool> x = {false};
std::atomic<bool> y = {false};
std::atomic<int> z = {0};

void write_x() {
    x.store(true, std::memory_order_seq_cst);
}

void write_y() {
    y.store(true, std::memory_order_seq_cst);
}

void read_x_then_y() {
    while (!x.load(std::memory_order_seq_cst))
        ;
    if (y.load(std::memory_order_seq_cst)) {
        ++z;
    }
}

void read_y_then_x() {
    while (!y.load(std::memory_order_seq_cst))
        ;
    if (x.load(std::memory_order_seq_cst)) {
        ++z;
    }
}

int main() {
    std::thread a(write_x);
    std::thread b(write_y);
    std::thread c(read_x_then_y);
    std::thread d(read_y_then_x);
    a.join(); b.join(); c.join(); d.join();
    assert(z.load() != 0); // will never happen
}

上面的代码中,read_x_then_yread_y_then_x 不可能看到相反的 xy 的赋值顺序,所以必至少有一个执行到 ++z

Seq-cst 和其它 ordering 混用时可能出现不符合预期的结果,如下面例子中,对 thread 1 来说,A sequenced before B,但对别的线程来说,它们可能先看到 B,很迟才看到 A,于是 C 可能看到 B,得到 r1 = 1,D 看到 E,得到 r2 = 3,F 看不到 A,得到 r3 = 0

// Thread 1:
x.store(1, std::memory_order_seq_cst); // A
y.store(1, std::memory_order_release); // B

// Thread 2:
r1 = y.fetch_add(1, std::memory_order_seq_cst); // C
r2 = y.load(std::memory_order_relaxed); // D

// Thread 3:
y.store(3, std::memory_order_seq_cst); // E
r3 = x.load(std::memory_order_seq_cst); // F
ARMv8 内存系统学习笔记