一、一些机制的思考¶
调度器X收到frame后,同时发给任务A和任务B,在任务A和任务B运行完成后,统一把结果返回给调度器。有两种实现方案:
- 对于每一帧,都要求完成任务A和任务B之后,把结果返回给调度器后,再运行下一帧。
- 任务A和任务B各自维护一个frame队列,某帧在做完某任务时,同步判断是否该帧的所有任务都运行完成了。若运行完成,则由该帧最晚运行完成任务所在的线程,把结果返回给调度器。
方案2的难点在于flush时,
二、chrono¶
cppreference - Date and time utilities C++20 丰富了好多 chrono 的方法。
2.1 clock¶
clock: a clock is a source of time information.four distinct pieces of information:
- the time now:
std::chrono::system_clock::now - the type of the value used to represent the times obtained from the clock.
- the tick period of the clock. the tick period of the clock is specified as a fractional number of seconds.
- whether or not the clock ticks at a uniform rate and is thus considered to be a steady clock.
2.2 duration¶
std::chrono::duration<baseType, fps>
例如:using std::chrono::milliseconds = std::chrono::duration<int64_t, std::milli>
其中:std::milli 为 std::ratio<1,1000>
获取duration的方式:
- 转换:
std::chrono::minutes(35) - 运算符重载:
using namespace std::chrono_literals; 15ms
2.3 time points¶
std::chrono::time_point<clockSource, tickPeriod>
The value of time point is the length of time (in multiples of the specified duration) since a specific point in time called the epoch of the clock.
常用方法:
std::chrono::xxx_clock::now()
std::chrono::time_since_epoch()
time_point和duration可以直接进行加减运算。
三、thread¶
template< class Function, class... Args >
explicit thread( Function&& f, Args&&... args );
//example: std::thread t1{transfer, std::ref(my_account), std::ref(your_account), 10};
void join(); // waits for the thread to finish its execution
void detach(); // permits the thread to execute independently from the thread handle
void joinable(); // checks whether the thread is joinable, i.e. potentially running in parallel context
std::thread::hardware_concurrency(); //返回硬件并发上下文数量(用作线程数选择的提示)。**实现定义**:标准不保证返回值能真实反映硬件,无法检测或无意义时返回 0。不能作为线程同步用。
标准与实现
std::thread 映射到 OS 线程(POSIX pthread / Win32 thread)属于实现细节,标准只规定其行为语义。hardware_concurrency() 的具体返回值由编译器/库实现定义(可为 0)。但 std::thread 的可联结性、join/detach 语义、析构行为由标准规定。
3.1 this_thread¶
template< class Rep, class Period >
void sleep_for( const std::chrono::duration<Rep, Period>& sleep_duration );
//example: std::this_thread::sleep_for(2000ms);
template< class Clock, class Duration >
void sleep_until( const std::chrono::time_point<Clock,Duration>& sleep_time );
//example: std::this_thread::sleep_until(awake_time());
// yield()放弃的是一次时间片,下一次调度还是可以正常运行的,对当前线程的性能影响比较小一点。
// 因此性能敏感的代码可以使用yield()。一般用于驱动程序。
void yield() noexcept;
3.2 TLS(thread local storage)¶
thread_specific_ptr(来自 Boost.Thread)代表了一个全局的指针,而在每个线程中都各自 new 一个线程本地的对象交给它进行管理,这样,各个线程就可以各自独立地访问这个全局变量的本地存储版本,线程之间就不会因为访问同一全局对象而引起资源竞争导致性能下降。而线程结束时,这个资源会被自动释放。
C++11 起标准库提供 thread_local:
thread_local std::string s("hello from ");
3.3 Date and time utilities¶
auto start = std::chrono::steady_clock::now();
// do some work
std::vector<int> v(size, 42);
sink = std::accumulate(v.begin(), v.end(), 0u); // make sure it's a side effect
// record end time
auto end = std::chrono::steady_clock::now();
std::chrono::duration<double> diff = end - start;
std::cout << "Time to fill and iterate a vector of " << std::setw(9)
<< size << " ints : " << diff.count() << " s\n";
auto awake_time() {
using namespace std::literals;//or using std::chrono::operator""ms;
return now() + 2000ms;
}
3.4 RAII¶
创建线程者在退出前要确保线程是不可联结的(unjoinable)(M条款37) 不可联结的线程:默认构造、已移动、已联结、已分离
未定义行为:joinable 的 std::thread 析构
对仍 joinable 的 std::thread 对象调用析构函数,标准会让程序调用 std::terminate() —— 整个程序直接终止,这是未定义行为的入口。析构前必须 join() 或 detach() 使其变为 unjoinable。
class ThreadRAII {
public:
ThreadRAII(std::thread&& t, DtorAction a)
: mThread(std::move(t)), mAction(a) {}
~ThreadRAII() {
if(mThread.joinable()){
if(mAction == DtorAction::join) { mThread.join(); }
else { mThread.detach(); }
}
}
auto& get() { return mThread; }
ThreadRAII(ThreadRAII&&) = default;
ThreadRAII& operator=(ThreadRAII&&) = default;
private:
DtorAction mAction;
std::thread mThread;
}; // 类定义末尾别忘了分号
3.5 非必要不使用¶
现代计算机体系结构会为每个CPU内核提供一个或多个硬件线程。
优先选用基于任务而非基于线程的程序设计(M条款35)的原因:
- 系统异常风险:软件线程(又称为操作系统线程或系统线程)是一种有限的资源,如果你尝试创建的线程数多于系统能够提供的数量,就会抛出std::system_error异常。
- 负载均衡问题:超订(oversubscription),就是就绪态的软件线程数超过了硬件线程数 → 时间片轮转 → 调度器切换开销变大
- 软件线程和硬件线程的最佳比例依赖于语境切换的成本,以及软件线程使用CPU缓存时的命中率。硬件线程的数量和CPU缓存的细节又取决于计算机体系结构。
直接使用线程的场景:
- 需要访问底层线程实现的API
- 有能力为应用优化线程的用法,实现超越C++并发API的线程技术。
四、lock¶
自旋锁在单处理器(单核)上必须配合禁用抢占或禁用中断使用,否则若持锁线程被抢占,忙等的同核线程会一直空转甚至死锁;只有多处理器环境下才能靠"另一个核终会释放锁"来安全忙等。自旋锁的实现与原理 锁:指进入临界区的判断标志
- 自旋锁(乐观锁):硬件等效操作:
while(LOCK==1); LOCK=1;(仅示意,实际必须用test_and_set等原子操作保证读改写的原子性)
class spinlock_mutex
{
std::atomic_flag flag;
public:
spinlock_mutex():
flag(ATOMIC_FLAG_INIT)
{}
void lock()
{
while(flag.test_and_set(std::memory_order_acquire));
}
void unlock()
{
flag.clear(std::memory_order_release);
}
};
- 互斥锁:硬件等效操作:
while(LOCK==1) {wait; } LOCK=1;
| 类型 | 声明 | 说明 |
|---|---|---|
| lock_guard | template< class Mutex > class lock_guard; |
自动释放 |
| unique_lock | template< class Mutex > class unique_lock; |
相对于lock_guard,unique_lock更为灵活: * 支持更高的颗粒度:支持在声明周期中提前解锁unlock()和再次进锁lock()。可通过bool owns_lock()查询锁状态。 * 代码分层:支持move操作,可以通过 gateway class 将锁返回给调用者。 * std::condition_variable requires std::unique_lock and so can only wait in unique ownership mode. |
| shared_lock(C++14) | template< class Mutex > class shared_lock; |
接收std::shared_mutex,以实现读互斥。并非有读写互斥的情形就一定能带来性能上的优化。性能依读写频次而定。std::unique_lock可用的方法和限制基本都继承了 |
| Recursive locking | ... | 一般认为是不合理的程序设计导致该需求,但 C++ 标准库仍提供了 std::recursive_mutex 与 std::recursive_timed_mutex(均自 C++11 起,头文件 <mutex>),允许同一线程多次加锁 |
| 类型 | 声明 | 说明 |
|---|---|---|
| std::mutex | ||
| std::shared_mutex | ||
| std::timed_mutex | ||
| std::recursive_timed_mutex |
五、一些概念¶
5.1 并发模型¶
5.1.1 线程和锁的内存共享模型¶
5.1.2 actor模型¶
5.1.3 CSP¶
functional-style programming : echo task produces a result entirely dependent on its input rather than on the external environment, and message passing where communicaiton between threads is via asynchronous messages sent through a messaging subsystem that acts as an intermediary. Communicating Sequential Processes: threads are conceptually entirely separate, with no shared data but with commuication channels that allow messages to be passed between then.
MPI(Message Passing Interface) environment commonly used for high-performance computing in C and C++.
5.2 基础概念¶
线程池:通过动态控制活跃线程的数量来权衡功耗性能,并避免不必要的 sys-cpu 消耗。
协程:用户空间下的任务调度。协程的并发,是单线程内控制权的轮转,相比抢占式调度,协程是主动让权,实现协作。C++20 起语言层面引入协程(co_await/co_yield/co_return),但库设施(如 std::task、网络 awaitable)尚不完整,多需借助框架(如 asio、cppcoro)。
IO:
- 同步IO: 优点:简单;缺点:IO阻塞,无法充分利用IO和CPU资源,效率低
- Native AIO:AIO可以支持一次发送多个不连续的异步IO请求,性能更好(同步IO需要发送多次)
- POSIX AIO:不依赖O_DIRECT选项,有一定的合并能力(相邻地址的请求,可以做merge)。
5.3 Alternative facilities¶
5.3.1 call_once¶
Protecting shared data during initialization Double-Checked Locking pattern 解决不了问题。常用于解决类静态变量的运行时初始化问题。
template< class Callable, class... Args >
void call_once( std::once_flag& flag, Callable&& f, Args&&... args );
class once_flag;
- The return from the returning call synchronizes-with the returns from all passive calls on the same flag
- If that invocation throws an exception, it is propagated to the caller of std::call_once, and flag is not flipped so that another call will be attempted.
example:
std::once_flag flag;
void simple_do_once()
{
std::call_once(flag, [](){ std::cout << "Simple example: called once\n"; });
}
5.3.2 scoped_lock(C++17)¶
待阅读
5.4 注意事项¶
- 避免在IO操作中持锁。
- 会在锁外运行的指针和引用,不应该是invariant。
- 调用对象内有锁,调用者依然可能出现冒险情形。如:
s.top(); 另一个线程再次打断; s.pop() - "加锁就安全"取决于是否覆盖所有共享访问——只要存在一处未受同一把锁保护的并发访问(哪怕只读+另一处写),就是数据竞争。
未定义行为:数据竞争(data race)
至少一个写、且线程间无 happens-before 关系的并发访问同一非原子对象 = 数据竞争,是 C++ 标准未定义行为。注意:标准对结果不作任何保证(不是"可能读到旧值"那么简单),编译器有权基于"程序无数据竞争"假设做激进优化(例如把共享变量缓存在寄存器里)。同步必须通过 std::atomic 或 mutex 建立 happens-before 关系,别用 volatile。
5.5 同时获取多把锁¶
//同时获取多个锁
template< class Lockable1, class Lockable2, class... LockableN >
void lock( Lockable1& lock1, Lockable2& lock2, LockableN&... lockn );
struct defer_lock_t { explicit defer_lock_t() = default; }; // do not acquire ownership of the mutex
struct try_to_lock_t { explicit try_to_lock_t() = default; }; // try to acquire ownership of the mutex without blocking
struct adopt_lock_t { explicit adopt_lock_t() = default; }; // assume the calling thread already has ownership of the mutex
example:
// lock both mutexes without deadlock
std::lock(from.m, to.m);
// make sure both already-locked mutexes are unlocked at the end of scope
std::lock_guard lock1{from.m, std::adopt_lock};
std::lock_guard lock2{to.m, std::adopt_lock};
// equivalent approach:
// std::unique_lock<std::mutex> lock1{from.m, std::defer_lock};
// std::unique_lock<std::mutex> lock2{to.m, std::defer_lock};
// std::lock(lock1, lock2);
六、sync¶
6.1 期值获取的接口 future¶
直接通过std::thread进行同步操作,需要考虑的因素较多,包括
- 调用方对thread手动进行detach或join
- 调用方的期值(即期望得到的值)需要手动同步处理。
针对期值手动管理的弊端,引入了future和promise。即:调用方 ← future ←← 共享状态(被调用方结果)←← std::promise ← 被调方。在std的体系中,期值一定是通过future或shared_future获取。而期值的提供可通过async、packaged_task、promise提供,上述三者对线程设计的要求程度依次提升,但灵活度也依次提升。async(在 std::launch::async 策略下)创建线程并保管函数运行与共享状态;packaged_task 不创建线程,但保管函数运行和内存共享,从而提供了 task 运行位置的自由度;promise 不创建线程、不管函数运行,只负责共享状态,灵活度最大。
future vs thread:
- std::thread 的对象和 future 对象都可以视作系统线程的句柄。
std::thread 对象的析构函数行为:针对可联结的 std::thread 调用析构函数会触发
std::terminate(),程序终止。 - future 对象的析构函数行为:可能是隐式 join、也可能是隐式 detach。
- 隐式 join:指涉到经由
std::async启动的未推迟任务(即std::launch::async)的共享状态的最后一个 future 会保持阻塞,直到该任务结束。 - 隐式 detach:其他所有 future 对象的析构函数仅仅将 future 析构就结束(共享状态被放弃)。
实现细节 vs 标准良定义
std::async(f) 返回的 future 在析构时阻塞等待任务完成,这一行为在标准中由"共享状态由 std::async 创建"这一约束间接规定(标准良定义),并非实现自由发挥。但 std::async 究竟是新起线程还是延迟到 get() 才执行,在策略为 async | deferred 默认值时是实现定义/未指定的。
关于期值存储位置的探讨:(M条款38)
- 首先它不会存储在被调方的 std::promise,因为那个对象对被调方是个局部变量,会在被调方结束后被析构。
- 然后它也不会存储在调用方的 future 中,因为 std::future 出发可以创建出 std::shared_future 对象(被调方的结果所有权被转移到 std::shared_future,类似 unique_ptr 构建 shared_ptr),但被调方的结果型别不都是可复制的。
- 所以,被调方的结果只能存储在两者外部的某个位置,这个位置被叫做共享状态。
6.1.1 调用方获取期值¶
std::future 为可异步获取的结果。这个结果可以是异步任务运行完成这件事本身,也可以是异步任务运行结束后取到的值。但只能在一个线程中获取(相比于 std::shared_future)。
bool valid() const noexcept; // valid() 返回 true 表示尚未 get();通常用 assert 检查
T get(); T& get(); void get(); //返回值类型由传入 std::future 的函数对象的返回值决定;get() 会阻塞当前线程直到异步操作完成,并返回操作的结果(一次性)。
void wait() const; // wait() 不会获取结果,只阻塞至就绪,返回 void。
//NOTE: std::future 发生异常并对外传播后,get 或 wait 可以用 try-catch 捕获到这个异常。[future 异常传播](https://blog.csdn.net/liushao1031177/article/details/116862635)
enum class future_status {
ready,
timeout,
deferred
};
template< class Rep, class Period > std::future_status wait_for( const std::chrono::duration<Rep,Period>& timeout_duration ) const;
template< class Clock, class Duration > std::future_status wait_until( const std::chrono::time_point<Clock,Duration>& timeout_time ) const;
std::shared_future<T> share() noexcept; // auto move future to shared_future.
6.1.2 async¶
future 虽为线程句柄,但和 thread 不同的是,其本身不创建线程,因此可通过 async 创建任务线程。为了简单易用,这个 API 是上层的 API,线程创建不是手动控制的。
template< class Function, class... Args >
std::future<std::invoke_result_t<std::decay_t<Function>, std::decay_t<Args>...>>
async( std::launch policy, Function&& f, Args&&... args ); // policy 可选(默认 async|deferred)
When to use std::launch::deferred?: std::launch::deferred 的任务直到 get() 被调用才执行(在调用 get() 的线程里同步运行);wait()/wait_for()/wait_until() 不会启动 deferred 任务,wait_for 还可能立即返回 future_status::deferred。
std::launch::async | std::launch::deferred(默认值)意味着 std::async 可以选其一,具体选哪个是实现定义/未指定的,受调度策略影响。std::async 启动策略的实现定义性
因此,如果异步是必要的,则显式指定 std::launch::async(M条款36),否则可能线程未真正启动、行为不可预测。若真要让系统决定 launch policy,则可以使用《C++标准库》中的例子。
example:
int find_the_answer_to_ltuae();
void do_other_stuff();
int main()
{
std::future<int> the_answer=std::async(find_the_answer_to_ltuae);
do_other_stuff();
std::cout<<"The answer is "<<the_answer.get()<<std::endl;
}
6.1.3 packaged_task¶
如果是通过队列将任务以可调用对象(如函数句柄)的方式传送,则适合使用std::packaged_task<>,从而避免使用客制化的条件变量。(多对一)
例子1:搜索书本Listing 4.9 Running code on a GUI thread using std::packaged_task
// 忽略队列的互斥操作
std::deque<std::packaged_task<void()> > tasks;
void gui_thread(){
...
std::packaged_task<void()> task;
task=std::move(tasks.front());
task();
}
template<typename Func>
std::future<void> post_task_for_gui_thread(Func f){
std::packaged_task<void()> task(f);
std::future<void> res=task.get_future();
tasks.push_back(std::move(task));
...//do something else
res.wait();//packaged_task needs to be invoked before you call f.get()
return res;
}
//The code that posted the message to the GUI thread can then
//wait for the future if it needs to know that the task has been completed, or it can discard
//the future if it doesn’t need to know
例子2:Difference between packaged_task and async (SO)
std::packaged_task<int(int,int)> task(...);
auto f = task.get_future();
std::thread myThread(std::move(task),2,3);
std::cout << f.get() << "\n";
6.1.4 比较async和packaged_task¶
Difference between packaged_task and async (SO)
使用背景上的差异:By using std::async you cannot run your task on a specific thread anymore, where std::packaged_task can be moved to other threads.
在API层级上的关系:a std::packaged_task is just a lower level feature for implementing std::async (which is why it can do more than std::async if used together with other lower level stuff, like std::thread). Simply spoken a std::packaged_task is a std::function linked to a std::future and std::async wraps and calls a std::packaged_task (possibly in a different thread).
使用方法上:async 必须结合 get() 使用;packaged_task 类似函数对象,必须被调用(task())才执行。
6.1.5 promises¶
当结果不仅仅从一个地方返回,或者任务不能被描述为一个函数时,则使用std::promise。在网络连接数比较大时,不能为每一个连接都新建一个线程(系统资源有限),需要一个调度器来控制内外连接(多对一对多)。
注:std::promise 要作为 std::thread 参数时,考虑到不可拷贝的特性,需要使用 std::move()
void set_value( const R& value );
void set_value( R&& value );
void set_value( R& value ); // 仅当 R 是引用类型时存在
void set_value(); // 仅当 R 为 void 时存在
std::future<R> get_future();
void do_work(std::promise<void> barrier){
std::this_thread::sleep_for(std::chrono::seconds(1));
barrier.set_value();
}
std::promise<void> barrier;
std::future<void> barrier_future = barrier.get_future();
std::thread new_work_thread(do_work, std::move(barrier));
barrier_future.wait();
new_work_thread.join();
需要注意的是:std::promises 通信通道不可重复使用。和async、packaged_task一样,只不过promises更容易产生可重复使用的错觉。
6.1.6 异常处理¶
- 若异步对象(如
std::async()、std::packaged_task<>()或者std::promise)中产生了异常,则在std::future调用get()时,将收到这个异常(或者异常的拷贝)。 std::promise允许设置异常
extern std::promise<double> some_promise;
try {
some_promise.set_value(calculate_value());
} catch (...) {
some_promise.set_exception(std::current_exception());
}
some_promise.set_exception(std::make_exception_ptr(std::logic_error("foo "))); // 已知异常类型时
- 若异步对象析构时,未能正确返回保证的返回值(如
std::promise未调用set_value、std::packaged_task未调用()),则共享状态会以错误码std::future_errc::broken_promise的std::future_error异常就绪。
6.2 条件变量¶
condition_variable
void notify_one() noexcept;
void notify_all() noexcept;
void wait( std::unique_lock<std::mutex>& lock );
template< class Predicate >
void wait( std::unique_lock<std::mutex>& lock, Predicate stop_waiting );
- [ ] 如果进入 wait 时
Predicate已满足,还会阻塞吗?(答:不会,带谓词的 wait 内部是while(!pred()) wait_unlocked;)
condition_variable_any
std::condition_variable_any can be used with std::shared_lock in order to wait on a std::shared_mutex in shared ownership mode.
template< class Lock > void wait( Lock& lock );
template< class Lock, class Predicate > void wait( Lock& lock, Predicate stop_waiting );
template< class Lock, class Predicate > bool wait( Lock& lock, std::stop_token stoken, Predicate stop_waiting ); // C++20
void notify_one() noexcept;
void notify_all() noexcept;
timeout:
- duration-based timeout:
wait_for() - absolute timeout:
wait_until()
使用条件变量,需要注意:
- 条件变量本身不具有互斥能力
- 如果检测任务在反应任务调用 wait 之前就通知了条件变量,则反应任务将错过这次通知、永远阻塞(丢失唤醒)。
- 反应任务的 wait 语句无法应对虚假唤醒(spurious wakeup)——标准允许
wait在没有 notify 的情况下自行返回,因此必须用带谓词的wait(lock, pred),或裸wait外包while(!pred) cv.wait(lk);循环。
未定义/错误行为:无谓词的 condition_variable wait
cv.wait(lock) 不带谓词时,若仅基于"通知到达"判断业务条件,将无法应对虚假唤醒,也容易丢失唤醒。错误使用还可能传入未持有锁的 unique_lock、或谓词在锁外读共享变量,触发数据竞争(未定义行为)。生产代码统一用 cv.wait(lk, [&]{ return ready; });。
6.2.1 平凡事件通信(M条款39)¶
锁+条件变量+标志位方案:
std::mutex m;
std::condition_variable cv;
bool flag = false;
//生产者
{
std::lock_guard<std::mutex> g(m);
flag = true;
}
cv.notify_one(); // 无需在锁中
//消费者
{
std::unique_lock<std::mutex> lk(m);
cv.wait(lk, [&]{ return flag; }); // 谓词形式应对虚假唤醒
}
先暂停再取消暂停的一次性事件通信:一般用于创建线程后,再做一些初始化动作
std::promise<void> p;
void detect(){
std::thread t([&](){ p.get_future().wait(); react(); }); // 注意按引用捕获 p
// ... 此处若出现异常,p 未 set_value,等待方将永远阻塞
p.set_value(); // 取消暂停
t.join();
}
七、基本概念¶
7.1 指令排序¶
并发(concurrency):把任务在不同的时间点交给处理器进行处理。 在同一时间点,任务并不会同时运行。 并行(parallelism):把每一个任务分配给每一个处理器独立完成。 在同一时间点,任务一定是同时运行。 内存并发访问(concurrent memory accesses):指多个线程或进程同时访问共享内存的情况。 内存栅栏(Memory Barrier)是一个令 CPU 或编译器在内存操作上限制内存操作顺序的指令,通常意味着在 barrier 之前的指令一定在 barrier 之后的指令之前执行。
指令重排序 有依赖关系的指令如果挨得很近,后一条指令必定会因为等待前一条执行的结果,而在流水线中阻塞很久,占用流水线的资源。(CPU重排序) 而编译器的重排序,作为编译优化的一种手段,则试图通过指令重排将这样的两条指令拉开距离,以至于后一条指令进入 CPU 的时候,前一条指令结果已经得到了,那么也就不再需要阻塞等待了。(编译重排序)
| 优化前 | 优化后 |
|---|---|
| a++; | a++; |
| a = f(b); | c++; |
| c++; | a = f(b); |
- 强顺序架构 (strongly-ordered architectures):对于多个线程而言,其看到的指令执行顺序是一致的。具体地,对于共享内存的处理器而言,需要看到内存中的数据被改变的顺序与机器指令中一致。x86 是强排序(TSO)。
- 弱顺序架构 (weakly-ordered architectures):目的是为了挖掘指令中的并行性。可能线程A所在的处理器看到指令执行顺序先 3 后 5,而线程 B 看到的指令顺序依然是先 3 后 5。ARM 和 PowerPC 是弱排序。
标准与实现
C++ 内存模型(happens-before、6 种内存序语义、原子操作可见性保证)是标准定义的抽象机,与 CPU 无关。强/弱顺序架构只是抽象机语义落地到具体 CPU 时用的指令实现差异——x86 是强模型,很多 acquire/release 编译成普通 MOV;ARM/POWER 是弱模型,需插 dmb/ldar/stlr 屏障。程序员只依赖标准抽象机语义,不依赖具体 CPU 的观测行为。
7.2 求值序¶
cppreference: Order of evaluation
Order of evaluation (求值) of any part of any expression, including order of evaluation of function arguments is unspecified (with some exceptions listed below).Evaluation of each expression includes:
- Value computations
- side effects
表达式/运算符优先级是求值序(evaluation Order)的重要部分,但并不是全部。
Sequenced before is an asymmetric, transitive, pair-wise relationship between evaluations within the same thread. “先序于”(sequenced before)是同一线程中求值表达式(evaluations)之间的一种不对称、可传递、成对的关系。并非指代码的先后关系,而是指指令执行并返回结果的先后关系。
由于指令排序的存在,可能出现:
- 表达式A可表达式B之前或之后,如
func(f1(), f2()); - 在拆解为汇编时糅合在一起(overlap)。如
a++;b++;
另外还有未定义的行为:
i = ++i + i++; // undefined behavior
i = ++i + 2; // well-defined
i = i++ + 2; // undefined behavior until C++17
f(i = -2, i = -2); // undefined behavior until C++17
f(++i, ++i); // undefined behavior until C++17, unspecified after C++17
7.3 内存序¶
C++ memory has weak memory model- the compiler can make reorder as he wants- he has to satisfy as if rule. The C++ memory model guarantees sequential consistency if you use atomic operations with the appropriate memory orderings to guarantee sequential consistency. If you just use plain non-atomic operations, or relaxed atomics, and no mutexes, then sequential consistency is not guaranteed.SO: Memory model in C++
内存序的维护需要编译器限制指令重排序和内存栅栏等的支持。
内存序就是高层对底层 cpu 内存模型的一种封装。知乎: C++ 内存模型 完全存储定序(TSO):如果有多条写操作指令,会严格按照FIFO的次序执行。可能出现 store-load 乱序。 部分存储定序(PSO):放弃写操作的FIFO,进一步可能出现 store-store 乱序。 宽松内存模型(RMO):除了 store-load 乱序、store-store 乱序,还会出现 load-load、load-store 乱序。
single total modification order (STMO)
https://en.cppreference.com/w/cpp/atomic/memory_order#Sequentially-consistent_ordering
Synchronize-with Preshing: The synchronizes-with relation:A Write-Release Can Synchronize-With a Read-Acquire.
7.4 atomic¶
引入内存模型的原因,有以下几个:C++ 内存模型入门
- 编译器优化:在某些情况下,即使是简单的语句,也不能保证是原子操作;
- CPU out-of-order:CPU为了性能,可能会调整指令的执行顺序;
- CPU Cache 不一致:在 CPU Cache 的影响下,在某个 CPU 下执行了指令,不会立即被其它 CPU 所看到。
原子性(atomicity):是指一个操作是不可中断,即使多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰; 有序性(修改顺序一致性,modification order consistency):在修改顺序一致性的情况下,内存操作不允许跨线程重新排序,包含 Write-write coherence、Read-read coherence、Read-write coherence、Write-read coherence。这意味着,如果一个线程执行写操作,然后执行读操作,则另一个线程在写操作之前无法观察到读操作(如果可以观察得到)。这确保了内存操作与原始线程执行它们的顺序一致。 可见性(非必有属性):当多个线程同时访问同一个变量时,一个线程修改了这个变量的值,其他线程能够~~立即~~看得到修改的值。
数据竞争 vs 可见性 —— 别混淆
"其他线程看不到修改"(可见性问题)只是表象。真问题是非原子对象上的无同步并发访问本身就构成数据竞争 = 未定义行为(Undefined Behavior),标准对结果不作任何保证(不是"读到旧值",而是"什么都能发生",包括编译器优化后的任意结果)。std::atomic 和 mutex 既是同步原语、也顺带保证可见性。
7.5 内存序控制量¶
若理解错概念,在使用过程中,可能是灾难性且难以排查的。C++ 通过内存序控制量来控制内存序,并屏蔽了底层的实现机制,这里不应该讨论底层实现。
https://en.cppreference.com/w/cpp/language/eval_order https://zhuanlan.zhihu.com/p/515382936?utm_id=0 https://preshing.com/20130823/the-synchronizes-with-relation/ https://en.cppreference.com/w/cpp/atomic/memory_order
Medium - Kohei Otsuka: Memory Model Basic: 分别举了 relaxed ordering 和 release-acquire ordering 不适用的例子。并以 "memory snapshots" 来阐明 知乎-四季花匠:C++11内存模型和原子类型:尝试重新解释 happens-before。原子烧脑,不推荐非专家写无锁程序。
- relaxed ordering:
memory_order_relaxed,只保证原子性和修改顺序一致性。 常用于增加/减少计数器数值。除原子自身外的变量不具有可见性。不同原子间也不具有可见性。 -[ ] 线程A修改原子M后,线程B读原子M的时效性是否比其他内存序方案更差? - release-acquire ordering:everything that happened-before a store in one thread becomes a visible side effect in the thread that did a load.
如果线程A中的原子
store被标记为memory_order_release,线程B中来自同一变量的原子load被标记为memory_order_acquire,并且 "theloadin thread B reads a value written by thestorein thread A",那么「线程A中存储动作」synchronizes-with「线程B中加载动作」。 synchronizes-with 具体表现:从线程A的角度来看,在原子store及之前发生的所有内存写入(包括非原子和 relaxed 原子),对于线程B也是可见的。但「线程B以及无关线程C」的操作对线程A不一定可见(acquire 是单向同步)。 常用于生产消费者模型,不推荐在多于 2 个线程中情形使用。 - release-Consume ordering:
If an atomic store in thread A is tagged memory_order_release, an atomic load in thread B from the same variable is tagged memory_order_consume, and the load in thread B reads a value written by the store in thread A, then the store in thread A is dependency-ordered before the load in thread B.
如果线程A中的原子
store被标记为memory_order_release,线程B中来自同一变量的原子load被标记为memory_order_consume,“theloadin thread B reads a value written by thestorein thread A”, 那么「线程A中存储动作」dependency-ordered before「线程B中加载动作」。 dependency-ordered before 指:Release-Consume 原子求值对;以及 load 该原子时 carries a dependency into 的求值表达式。
A carries a dependency into B 指:满足 sequence-before,且满足以下任一条件:
- B 是 X 的操作数,且 X 未调用
std::kill_dependency,且 B 不为&&、||、?:、,的左操作数。 - X 为标量赋值
X = A,且 B 为 X - 语义递归
memory_order_consume 在标准中被边缘化
自 C++17 起,memory_order_consume 的标准语义因为发现实现困难被显著弱化,主流编译器实际把它当作 memory_order_acquire 处理。除非你完全清楚自己在做什么,不要使用 consume——直接用 acquire。
总之,B.load之后 carries dependency 的evaluation,才具有 visible side-effects 。
Sequentially-consistent ordering:
指令排序包含求值序和多线程的内存序的影响。
7.5.1 volatile¶
volatile 变量的意义在于每次读写都会从内存读或者写内存,解决的是编译器优化的问题(防编译器把变量缓存进寄存器)。知乎: C++ 内存模型
volatile 只能保证涉及每个 volatile 变量的代码的相对顺序不会被编译器重排,至于 volatile 变量的代码和其他非 volatile 变量的代码之间的相对顺序并不保证,且无法保证 CPU 不会继续重排你的代码。
C/C++ 多线程编程中不要使用 volatile 做同步。(注:这里指的是指望 volatile 解决多线程竞争问题是有很大风险的,除非所用的环境/系统不可靠才会为了保险加上 volatile,或者是从极限效率考虑来实现很底层的接口。这要求编写者对程序逻辑走向很清楚才行,不然就会出错。)C++11 标准中明确指出解决多线程的数据竞争问题应该使用原子操作或互斥锁。知乎: 为什么 C/C++ 多线程不靠 volatile
未定义行为:volatile 不能用于线程同步
volatile 在 C++ 中不是同步原语——它不建立 happens-before 关系、不阻止数据竞争、不保证可见性、不阻止 CPU 重排。用 volatile 来"标记一个变量被多线程读写"是典型的误解(这是 Java/C# volatile 的语义,C++ 不是)。两个线程无同步地通过 volatile int 读写依然是数据竞争 = 未定义行为。并发同步只能用 std::atomic 或 mutex。volatile 的真实用途是:信号处理、内存映射 IO、setjmp 等场景,防编译器对该次访问做优化。
(条款40)
std::atomic 对于并发程序设计有用,但不能用于访问特种内存。
volatile 对访问特种内存有用,但不能用于并发程序设计。
因此就有了 volatile std::atomic<int> vai;,既针对 vai 进行原子操作,又保证了不被优化掉(如 a = b; a = c; 这两步都会运行),并明确了 side effect 不可改变。
八、atomic¶
相对于mutex,atomic一般针对只有简单复制和比较的对象。
std::atomic_flag默认是 lock free 、 false的。更接近于指令本身,因此没有读方法。若需要读,则使用std::atomic<bool>
如果当前的变量 this 的值 == expected 值,则将 this 改为 desired,并返回 true;否则把 this 的当前值写回 expected,返回 false(即"比较-交换"原子完成,附带一次读)。常用于线程B等待线程A执行到某步骤——线程B做 while 循环 CAS,线程A执行到对应步骤后将原子变量改值即可。类似于"接力运动"。 compare_exchange 详解
bool compare_exchange_strong( T& expected, T desired,
std::memory_order order =
std::memory_order_seq_cst ) noexcept;
-
[ ] UDT(用户自定义类型)作为 atomic 模板参数时的对齐与 lock-free 保证待补
-
x.load()等价于std::atomic_load(&x)(C 兼容形式)。若想显式声明内存序,则调用std::atomic_load_explicit(&x, memory_order) std::shared_ptr<>通过指针的形式存入std::atomic<>。如std::atomic_load(&p)、std::atomic_store(&p, local)(注:C++20 起推荐用std::atomic<std::shared_ptr<T>>)
原子操作,一般都是指"不可分割的操作";是一系列不可被 CPU 上下文交换的机器指令,这些指令组合在一起就形成了原子操作。在多核 CPU 下,原子操作通过 CPU 指令(如 x86 的 LOCK CMPXCHG、ARM 的 LL/SC)配合缓存一致性协议(如 MESI)保证其不可分割;早期实现可能锁总线,现代实现多采用"缓存锁",不会真的"暂停其他 CPU 核心"。掘金: 一文搞懂原子操作
由于原子操作是通过指令提供的支持,因此它的性能相比锁和消息传递会好很多。相比较于锁而言,原子类型不需要开发者处理加锁和释放锁的问题,同时支持修改、读取等操作,还具备较高的并发性能,几乎所有的语言都支持原子类型。
可以说 atomic + 内存序两者叠加才真正在 lock-free(免锁)情况下实现了高层代码顺序和底层代码执行顺序的统一。
字节对齐的 int 类型变量在字/双字对齐的系统上读写可能天然具有原子性(实现定义,非标准保证),但读写仍不建立同步、仍可能被重排。为了保证不同平台的可移植性与同步语义,必须用 std::atomic<int>。
atomic 不可拷贝和移动。
https://en.cppreference.com/w/cpp/thread
如果当前的变量 this 的值 == expected 值,则将 this 改为 desired,并返回 true;否则把 this 的当前值写回 expected,返回 false(即"比较-交换"原子完成,附带一次读)。常用于线程B等待线程A执行到某步骤——线程B做 while 循环 CAS,线程A执行到对应步骤后将原子变量改值即可。类似于"接力运动"。 compare_exchange 详解
bool compare_exchange_strong( T& expected, T desired,
std::memory_order order =
std::memory_order_seq_cst ) noexcept;
九、作业¶
一个作业通常包括几个进程,几个进程共同完成一个任务,即作业。 用户提交作业以后,当作业被调度,系统会为作业创建进程,一个进程无法完成时,系统会为这个进程创建子进程。[*]
- parallelism(并行): two tasks literally run at the same time
- Concurrency(并发): two tasks can be ‘in progress’ at the same time
十、进程与线程¶
进程(最小的资源单位), 线程(最小的执行单位)
A process is an instance of a computer program containing binary code along with the resources above.Multi-thread and multi-process(Medium)
And a thread is a component of a process. It is an execution unit and it contains program counter, stack and set of registers.
The main difference between them is whether they share memory or not.
十一、一些机制的思考¶
调度器X收到frame后,同时发给任务A和任务B,在任务A和任务B运行完成后,统一把结果返回给调度器。有两种实现方案:
- 对于每一帧,都要求完成任务A和任务B之后,把结果返回给调度器后,再运行下一帧。
- 任务A和任务B各自维护一个frame队列,某帧在做完某任务时,同步判断是否该帧的所有任务都运行完成了。若运行完成,则由该帧最晚运行完成任务所在的线程,把结果返回给调度器。
方案2的难点在于flush时,
十二、基本概念¶
12.1 内存序¶
概括:relaxed ordering 只保证原子性和该原子变量自身的修改顺序一致性,不对其他内存读写建立 happens-before 关系,不强制刷新缓存或建立可见性。(早期笔记里"不刷新当前缓存"的说法不准确:relaxed 不约束缓存行为,仅约束语义序。)
weakly-ordered architectures strongly-ordered architectures
内存序就是高层对底层 cpu 内存模型的一种封装。知乎: C++ 内存模型 完全存储定序(TSO):如果有多条写操作指令,会严格按照 FIFO 的次序执行。可能出现 store-load 乱序。 部分存储定序(PSO):放弃写操作的 FIFO,进一步可能出现 store-store 乱序。 宽松内存模型(RMO):除了 store-load 乱序、store-store 乱序,还会出现 load-load、load-store 乱序。
single total modification order (STMO)
https://en.cppreference.com/w/cpp/atomic/memory_order#Sequentially-consistent_ordering
- relaxed ordering:
memory_order_relaxed,只保证原子性和修改顺序一致性。 常用于增加计数器数值。虽然不建立跨变量的可见性,但若线程A与线程B都只对该原子变量做 relaxed 操作,则它们看到的修改顺序是一致的。 - release-acquire ordering:everything that happened-before a store in one thread becomes a visible side effect in the thread that did a load.
如果线程A中的原子
store被标记为memory_order_release,线程B中来自同一变量的原子load被标记为memory_order_acquire,并且 "theloadin thread B reads a value written by thestorein thread A",那么「线程A中存储动作」与「线程B中加载动作」同步。 这个同步具体表现:从线程A的角度来看,在原子store及之前发生的所有内存写入(包括非原子和 relaxed 原子),对于线程B也是立即可见的。但「线程B以及无关线程C」的操作对线程A不一定可见(acquire 是单向同步)。 常用于生产消费者模型。 - Release-Consume ordering:
12.1.1 volatile¶
volatile 变量的意义在于每次读写都会从内存读或者写内存,解决的是编译器优化的问题(防编译器把变量缓存进寄存器)。知乎: C++ 内存模型
volatile 只能保证涉及每个 volatile 变量的代码的相对顺序不会被编译器重排,至于 volatile 变量的代码和其他非 volatile 变量的代码之间的相对顺序并不保证,且无法保证 CPU 不会继续重排你的代码。
volatile ≠ 同步原语
再次强调:C++ 的 volatile 不建立 happens-before、不消除数据竞争(数据竞争仍是未定义行为)、不保证可见性。并发同步必须用 std::atomic 或 mutex。
十三、atomic¶
原子操作,一般都是指"不可分割的操作";是一系列不可被 CPU 上下文交换的机器指令,这些指令组合在一起就形成了原子操作。在多核 CPU 下,原子操作通过 CPU 指令(如 x86 的 LOCK CMPXCHG、ARM 的 LL/SC)配合缓存一致性协议(如 MESI)保证其不可分割;早期实现可能锁总线,现代实现多采用"缓存锁",并不会真的"暂停其他 CPU 核心"。掘金: 一文搞懂原子操作
由于原子操作是通过指令提供的支持,因此它的性能相比锁和消息传递会好很多。相比较于锁而言,原子类型不需要开发者处理加锁和释放锁的问题,同时支持修改、读取等操作,还具备较高的并发性能,几乎所有的语言都支持原子类型。
可以说 atomic + 内存序两者叠加才真正在 lock-free(免锁)情况下实现了高层代码顺序和底层代码执行顺序的统一。
字节对齐的 int 类型变量在字/双字对齐的系统上读写可能天然具有原子性(实现定义,非标准保证),但读写仍不建立同步、仍可能被重排。为了保证不同平台的可移植性与同步语义,必须用 std::atomic<int>。
atomic 不可拷贝和移动。
https://en.cppreference.com/w/cpp/thread
如果当前的变量 this 的值 == expected 值,则将 this 改为 desired,并返回 true;否则把 this 的当前值写回 expected,返回 false(即"比较-交换"原子完成,附带一次读)。常用于线程B等待线程A执行到某步骤——线程B做 while 循环 CAS,线程A执行到对应步骤后将原子变量改值即可。类似于"接力运动"。 compare_exchange 详解


