文章目录
- 基本概念
- 编程
- 创建线程
- 启动
- 共享数据相关
- 条件变量
- 时间相关
- future相关——等待一次性事件
- 读写锁
- 原子操作与缓存一致性关系
- 线程管理
- 启动线程
- 从类的方法来创建线程
- 传参
- 标识线程
- 常用API
- 等待线程完成
- 后台运行
- 线程移动
- 线程间共享数据
- 互斥量(mutex)
- unique_ptr
- 线程池
- 内存
- 内存对齐
- 类的内存
- 内存管理
- atomic
- 内存顺序
- 内存模型
- 并发存在的问题
基本概念
线程与进程:
任务并行与数据并行:两种方式利用并发提高性能:第一,将一个单个任务分成几部分,且各自并行运行,从而降低总运行时间。这就是任务并行(task parallelism)。虽然这听起来很直观,但它是一个相当 复杂的过程,因为在各个部分之间可能存在着依赖。区别可能是在过程方面——一个线程执行算法的一部分,而另一个线程执行算法的另一个部分——或是在数据方面——每个线程在不同的数据部分上执行相同的操作(第二种方式)。后一种方法被称为数据并行(data parallelism)
何时使用并发:除非潜在的性能增益足够大或关注点分离地足够清晰,能抵消所需的额外的开发时间以及与维护多线程代码相关的额外成本(代码正确的前提下);否则,别用并发。
编程
多线程编程实例:
- 添加头文件:
#include <thread>
- 创建线程对象:
std::thread t1(foo2);
或者std::thread t1(foo2, params);
其中第一个表示通过无参数的函数创建线程对象,另外一个表示通过函数和相对于的参数创建线程对象 t1.join()
等待线程结束
创建线程
- 从普通函数名创建
std::thread th(func, args)
- 从类的成员函数创建
类内部:非成员函数创建线程时,传入this指针,类外部则改为指向类实例对象的指针。
静态成员函数:
std::thread th(&Class::Func, args)
非静态成员函数:
std::thread th(&Class::Func,this,args)
- 使用std::bind函数
std::thread th(std::bind(&Class::Func,this, args))
- 使用lambda表达式创建
std::thread th([](){func();};)
启动
std::thread::hardware_concurrency()
:硬件线程数量
detach()
join()
joinable()
:返回是否可被join()
std::move(th);
:线程只能被移动,不能被拷贝复制。
标识:
std::thread::id
get_id()
暂停线程:
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
量产
std::for_each(threads.begin(), threads.end(), std::mem_fn(&std::thread::join));
共享数据相关
常见问题:
- 条件竞争(race condition);当不变量遭到破坏时,才会产生条件竞争,对数据的条件竞争通常表示为“恶性”(problematic)条件竞争。
- 数据竞争(data race)一种特殊的条件竞争:并发的去修改一个独立对象(参见5.1.2节)。
解决办法:
- 互斥量、锁:mutex
- 事务:transacting
避免死锁:
- 避免嵌套锁:尽量保证每个线程只持有一个锁,锁上就不会产生死锁;如果持有多个锁,那么使用std::lock();
- 避免在持有锁时调用用户提供的代码
- 使用固定顺序获取锁
当其中一个成员函数返回的是保护数据的指针或引用时,会破坏对数据的保护。
互斥量保护的数据需要对接口的设计相当谨慎,要确保互斥量能锁住任何对保护数据的访问,并且不留后门。
已经对锁的粒度有所了解:锁的粒度是一个“摆手术语”(hand-waving term),用来描述通过一个锁保护着的数据量大小。一个细粒度锁(a fine-grained lock)能够保护较小的数据量,一个粗粒度锁(a coarse-grained lock)能够保护较多的数据量。
std::mutex mtx;
-
lock();
:上锁 -
unlock();
:解锁 -
std::lock(mtx1, mtx2)
:一次性锁住多个互斥量,并且没有副作用(死锁风险)
std::std::recursive_mutex
:嵌套锁一般用在可被多线程并发访问的类上,所以其拥有一个互斥量保护其成员数据。每个公共成员函数都会对互斥量上锁,然后完成对应的功能,之后再解锁互斥量
std::lock_guard<std::mutex> guard(mtx);
std::unique_lock<std::mutex> ulck(mtx);
ulck(mtx, std::adopt_lock)
等
条件变量
如果互斥量在线程休眠期间保持锁住状态,准备数据的线程将无法锁住互斥量,也无法添加数据到队列中;同样的,等待线程也永远不会知道条件何时满足。
std::condition_variable con_var;
wait(ulck)
wait(ulck, bool)
wait()会去检查这些bool条件,当条件满足返回(程序继续执行)。如果条件不满足,wait()函数将解锁互斥量,并且将这个线程置于阻塞或等待状态。
notify_one()
当准备数据的线程调用notify_one()通知条件变量时,处理数据的线程从阻塞或者等待状态中苏醒,重新获取互斥锁,并且对条件再次检查——当条件不满足时,线程将对互斥量解锁,并且重新开始等待。在条件满足的情况下,从wait()返回并继续持有锁。
应用场景:当线程用来分解工作负载,并且只有一个线程可以对通知做出反应
这就是为什么用 std::unique_lock 而不使用 std::lock_guard ——等待中的线程必须在等待期间解锁互斥量,并在这这之后对互斥量再次上锁,而 std::lock_guard 没有这么灵活。
notify_all()
应用场景:很多线程等待同一事件,对于通知他们都需要做出回应。这会发生在共享数据正在初始化的时候,当处理线程可以使用同一数据时,就要等待数据被初始化(有不错的机制可用来应对;
时间相关
限定等待一个事件的时间:wait_for()和wait_until()
std::cv_status::timeout
std::condition_variable cv;
std::mutex m;
auto now = std::chrono::steady_clock::now();
std::unique_lock<std::mutex> lk(m);
cv.wait_until(lk, now + std::chrono::milliseconds(500));
cv.wait_for(lk, std::chrono::milliseconds(35));
if(cv.wait_until(lk, timeout)==std::cv_status::timeout)
break;
//====================================================
std::package<Data> promise;
std::future<Data> future = promise.get_future();
future.wait_for(std::chrono::milliseconds(35));
//====================================================
当线程因为指定时延而进入睡眠时,可使用sleep_for()唤醒;或因指定时间点睡眠的,可使用sleep_until唤醒,超时和延迟处理功能:std::this_thread::sleep_for() 和 std::this_thread::sleep_until()
future相关——等待一次性事件
应用场景:当等待线程只等待一次,当条件为true时,它就不会再等待条件变量了,所以一个条件变量可能并非同步机制的最好选择。尤其是,条件在等待一组可用的数据块时。在这样的情况下,期望(future)就是一个适合的选择。
“期望”对象本身并不提供同步访问。当多个线程需要访问一个独立“期望”对象时,他们必须使用互斥量或类似同步机制对访问进行保护
唯一期望:std::future 的实例只能与一个指定事件相关联
std::async()
std::future<Data> future = std::async(std::launch::async, func)
std::future<Data> future = std::async(std::launch::deffered, func)
Data data = future.get();
future.wait();
std::packaged_task<>
:对一个函数或可调用对象,绑定一个期望;它就会调用相关函数或可调用对象,将期望状态置为就绪,返回值也会被存储为相关数据。
应用场景:比如在任务所在线程上运行任务,或将它们顺序的运行在一个特殊的后台线程上。当一个粒度较大的操作可以被分解为独立的子任务时,其中每个子任务就可以包含在一个 std::packaged_task<> 实例中,之后这个实例将传递到任务调度器或线程池中。
- std::packaged_task<> 的模板参数是一个函数签名,如
std::packaged_task<double(double)>
std::packaged_task<int(std::string)> pk_task(func)
:func的函数签名必须是符合其模板参数。
调用pk_task(std::string s)
是一个可调用对象。
pk_task.get_future()
返回的future对象含有函数的调用结果。
std::promise/std::future
在future上可以阻塞等待线程,同时,提供数据的线程可以使用组合中的“promise”来对相关值进行设置,以及将“future”的状态置为“ready”;
”解决单线程多连接问题
std::promise<Data> promise;
-
promise.set_value(Data);
-
std::future<Data> future = promise.getfuture();
std::advance()
共享期望: std::shared_future 的实例就能关联多个事件
std::shared_future<Data>
带返回值的后台任务:因为 std::thread 并不提供直接接收返回值的机制。这里就需要 std::async 函数模板(也是在头文件中声明的)了。
每个线程都通过自己拥有的 std::shared_future 对象获取结果,那么多个线程访问共享同步结果就是安全的
- 使用std::async 启动一个异步任务
- std::async 会返回一个 std::future 对象,这个对象持有最终计算出来的结果
- 当你需要这个值时,你只需要调用这个对象的get()成员函数;并且直到“期望”状态为就绪的情况下,线程才会阻塞。
通过future来进行初始化:
std::promise<Data> promise;
std::shared_future<Data> sf(promise.get_future());
std::future<Data> f(p.get_future());
std::shared_future<Data> sf(std::move(f))
auto sf = f.share();
层次锁:层级互斥量的实现。
hierarchical_mutex high_mtx(100);
函数化编程”(functional programming ( FP ))引用于一种编程方式,这种方式中的函数结果只依赖于传入函数的参数,并不依赖外部状态
替代同步的解决方案:函数化模式编程FP,完全独立执行的函数,不会受到外部环境的影响;消息传递模式FSP,以消息子系统为中介,向线程异步发送消息。
读写锁
原子操作与缓存一致性关系
当程序中的对同一内存地址中的数据访问存在竞争,你可以使用原子操作来 避免未定义行为。当然,这不会影响竞争的产生——原子操作并没有指定访问顺序——但原子操作把程序拉回了定义行为的区域内。
原子操作是一类不可分割的操作,当这样操作在任意线程中进行一半的时候,你是不能查看的;它的状态要不就是完成,要不就是未完成
std::atomic_flag()
解决缓存一致性问题?
CPU是多核的,写回策略会导致缓存一致性问题。
-
写传播——总线嗅探
-
事务的串行化——锁
-
优化:尽量广播给持有相关数据的核心而不是所有核心
MSEI一致性协议来实现!!!
M,已修改:某个数据块已修改到CPU cache但是没有同步到内存中
E,独占:某个数据块只在某个核心中,此时缓存和内存中数据一致
S,共享:某个数据块在多个核心都有,缓存和内存中数据一致。
I,已失效:某个数据块在核心中已失效,不是最新的数据。
——多核下,确保M、E这两个状态发生改变,任何尝试改变这两种状态的操作进行阻塞,实现原子操作。
线程管理
启动线程
void do_some_work();
std::thread my_thread(do_some_work);
对于类:可以通过重载()
运算符来实现。
class background_task {
public:
void operator()() const {
do_something();
do_something_else(); }
};
background_task f;
std::thread my_thread(f);
注意:当把函数对象传入到线程构造函数中时,需要避免“最令人头痛的语法解析”——如果传递一个临时变量,而不是一个命名的变量;C++编译器会将其解析成函数声明,而不是类型对象的定义。
std::thread my_thread(background_task());
这里相当与声明了一个名为my_thread的函数,这个函数带有一个参数(函数指针指向没有参数并返回background_task对象的函数),返回一个 std::thread 对象的函数,而非启动了一个线程。
解决办法:
std::thread my_thread((background_task())); // 1
std::thread my_thread{background_task()}; // 2
必须在 std::thread 对象销毁之前做出决定——加入或分离线程之前。 如果线程就已经结束,想再去分离它,线程可能会在 std::thread 对象销毁之后继续运行下去
使用一个能访问局部变量的函数去创建线程是一个糟糕的主意(除非十分确定线程会在函数完成前结束)
从类的方法来创建线程
创建线程用的函数指针是类方法的指针(&foo::bar),虽然这个方法没有声明参数,但是熟悉C++对象的朋友应该都知道,类方法隐含了自身实例的self指针,所以这里需要传给它的第一个参数就是指向实例foo_inst的指针。
class foo {
void bar() {
// ...
}
void create_thread() {
this->my_thread = new std::thread(&foo::bar, this);
}
// ...
private:
std::thread* my_thread;
};
传参
传递引用:std::ref(data)
标识线程
线程直接调用get_id
函数来获取每个线程的id,即std::thread::id 对象,该对象可以自由的拷贝和对比,因为标识符就可以复用。如果两个对象的 std::thread::id 相等,那它们就是同一个线程,或者都“没有线程”。如果不等,那么就代表了两个不同线程,或者一个有线程,另一没有。
说明:std::thread::id 实例常用作检测线程是否需要进行一些操作,例如
std::thread::id master_thread;
if(std::this_thread::get_id()==master_thread)
{
do_master_thread_work();
}
要保证ID比较结果相等的线程,必须有相同的输出。
常用API
在前面加std::
进行调用
API | 参数 | 说明 |
---|---|---|
get_id | void | 用于标识线程 |
move |
等待线程完成
就可以确保局部变量在线程完成后,才被销毁
my_thread.join()
只能对一个线程使用一次join();一旦已经使用过join(), std::thread 对象就不能再次加入了,当对其使用**joinable()**时,将返回否(false)
特殊情况下:
需要对一个还未销毁的 std::thread 对象使用join()或detach()。如果想要分离一个线程,可以在线程启动后,直接使用**detach()进行分离。如果打算等待对应线程,则需要细心挑选调用join()**的位置。
当倾向于在无异常的情况下使用join()时,需要在异常处理过程中调用join(),从而避免生命周期的问题
try {
do_something_in_current_thread();
}
catch(...) {
t.join(); // 1
throw;
}
t.join(); // 2
后台运行
使用detach()会让线程在后台运行,调用 std::thread 成员函数1detach()来分离一个线程。之后,相应的 std::thread 对象就与实际执行的线程无关了,并且这个线程也无法进行加入:
std::thread t(do_background_work);
t.detach();
assert(!t.joinable());
线程传递引用,提供的参数可以"移动"(move),但不能"拷贝"(copy)。"移动"是指:原始对象中的数 据转移给另一对象,而转移的这些数据就不再在原始对象中保存了(译者:比较像在文本编辑 时"剪切"操作)。 std::unique_ptr 就是这样一种类型(译者:C++11中的智能指针),这种类型 为动态分配的对象提供内存自动管理机制(译者:类似垃圾回收)。同一时间内,只允许一 个 std::unique_ptr 实现指向一个给定对象,并且当这个实现销毁时,指向的对象也将被删除 。
C++标准库中有很多资源占有(resource-owning)类型,比如 std::ifstream , std::unique_ptr 还有 std::thread 都是可移动(movable),但不可拷贝 (cpoyable)
线程移动
std::thread 支持移动,就意味着线程的所有权可以在函数外进行转移。
std::thread f() {
void some_function();
return std::thread(some_function);
}
std::thread g() {
void some_other_function(int);
std::thread t(some_other_function,42);
return t;
}
线程间共享数据
问题:无论结果如何,都是并行代码常见错误:条件竞争(race condition)
数据竞争:数据竞争(data race)这个术语,一种特殊的条件竞争:并发的去修改一个独立对象(参见5.1.2节)
恶性条件竞争通常发生于完成对多于一个的数据块的修改时,例如,对两个连接指针的修改 (如图3.1)。因为操作要访问两个独立的数据块,独立的指令将会对数据块将进行修改,并且其中一个线程可能正在进行时,另一个线程就对数据块进行了访问。因为出现的概率太低,条件竞争很难查找,也很难复现。如CPU指令连续修改完成后,即使数据结构可以让其他并发线程访问,问题再次复现的几率也相当低。当系统负载增加时,随着执行数量的增加,执行序列的问题复现的概率也在增加,这样的问题只可能会出现在负载比较大的情况下。
条件竞争通常是时间敏感的,所以程序以调试模式运行时,它们常会完全消失,因为调试模式会影响程序的执行时间(即使影响不多)。
如何避免恶性条件竞争?
- 最简单的办法就是对数据结构采用某种保护机制,确保只有进行修改的线程才能看到不变量被破坏时的中间状态
- 另一个选择是对数据结构和不变量的设计进行修改,修改完的结构必须能完成一系列不可分割的变化,也就是保证每个不变量保持稳定的状态,这就是所谓的无锁编程(lock-free programming)
- 使用事务(transacting)的方式去处理数据结构的更新(这里的"处理"就如同对数据库进行更新一样)。所需的一些数据和读取都存储在事务日志中,然后将之前的操作合为一步,再进行提交:当数据结构被另一个线程修改后,或处理已经重启的情况下,提交就会无法进行,这称作为“软件事务内存”(software transactional memory (STM))
互斥量(mutex)
unique_ptr
线程池
在用户线程中调用AfxGetMainWnd()函数,获取的不是应用程序主框架类指针,而是线程的m_pMainWnd。
https://blog.csdn.net/lidandan2016/article/details/72154490
内存
内存对齐
对于32位系统,其内存对齐至少为一个地址长度,也即 2 32 2^{32} 232即4个字节长度,可以使用alignas(4的倍数)来表示想要对齐的内存大小,指针类型的大小为4个字节。
对于64位系统,其内存对齐至少为一个地址长度,也即 2 64 2^{64} 264即8个字节长度,可以使用alignas(8的倍数)来表示想要对齐的内存大小,指针类型的大小为8个字节。
类的内存
空类的内存为1个字节,为了保证类的唯一实例化,以64位系统为例即指针大小为8个字节说明内存的详细情况:
内存管理
原子操作:std::atomic:要么完成,要么没完成,不存在中间状态
互斥量与原子操作不同点:
-
互斥量的加锁一般是针对一个代码段(几行代码);原子操作针对的都是一个变量,而不是针对一个代码段;
-
原子操作比互斥量在效率上更高
步骤:最常见的操作是原子读改写,简称RMW
- Read
- Modified
- Write
如何保证读和写保持顺序一致?下面这种是没有用锁和原子操作等机制,会发现两个线程一直保持独立!!!
int g_value;
void read() {
while (true)
{
cout << g_value << endl;
}
}
void write() {
while (true)
{
std::this_thread::sleep_for(std::chrono::seconds(1));
g_value++;
}
}
一般地:使用条件变量+锁asdsds
condition_variable con_var;
mutex g_mtx;
int g_value;
void read() {
while (true)
{
unique_lock<mutex> ul(g_mtx);
con_var.wait(ul);
cout << g_value << endl;
}
}
void write() {
while (true)
{
std::this_thread::sleep_for(std::chrono::milliseconds(100));
g_value++;
con_var.notify_one();
}
}
atomic
std::atomic_flag ato;
-
.test_and_set()
:-
1.如果atomic_flag ==true,返回true;
-
2.如果atomic_flag==false,返回false,并设置atomic_flag=true。
指定memory_order顺序设置。
-
-
.clear():设置atomic_flag =false
自旋锁与互斥锁:
- 一种是没有获取到锁的线程就一直循环等待判断该资源是否已经释放锁,这种锁叫做自旋锁,它不用将线程阻塞起来(NON-BLOCKING);
std::atomic_flag lock = ATOMIC_FLAG_INIT;
while (lock.test_and_set(std::memory_order_acquire)) // acquire lock
; // spin
dosomthing();
lock.clear(std::memory_order_release);
- 还有一种处理方式就是把自己阻塞起来,等待重新调度请求,这种叫做
互斥锁
原子类型对象的主要特点:
-
从不同线程访问不会导致数据竞争(data race)。
-
因此从不同线程访问某个原子对象是良性 (
well-defined
) 行为通常对于非原子类型而言,并发访问某个对象(如果不做任何同步操作)会导致未定义 (
undifined
) 行为发生。
std::atomic<T>
:不允许拷贝构造和拷贝赋值
std::atomic<bool> ato1 = false;
std::atomic<bool> ato3(false);
// std::atomic<bool> ato2 = ato1; // 错误,不能拷贝赋值
// std::atomic<bool> ato3(ato1); // 错误,不能拷贝构造
-
.is_lock_free()
:判断对象是否可lock-free即多个线程访问对象时会不会导致线程阻塞。 -
.store(T val, memory_order)
:修改被封装的值 -
.load(memory_order)
:读取被封装的值 -
T .exchange(T val, memory_order)
:修改值为新值,并返回之前的值 -
bool .compare_exchange_weak(T& expected, T val, memory_order)
:比较并交换被封装的值(weak
)与参数expected
所指定的值是否相等,整个操作是原子的,在某个线程读取和修改该原子对象时,另外的线程不能读取和修改该原子对象,如果:-
相等,则原子变量值=val,返回true。
-
不相等,则expected =原子变量值,返回false。
调用该函数之后,如果被该原子对象封装的值与参数
expected
所指定的值不相等,expected
中的内容就是原子对象的旧值。
-
-
bool .compare_exchange_strong(T& expected, T val, memory_order)
等
std::atomic<T>
:基本操作有fetch_add()和fetch_sub()提供,属于RMW操作。
fetch_add(T* ptr, memory_order)
:在存储地址上做原子加法fetch_sub(T* ptr, memory_order)
:在存储地址上做原子减法
std::atomic<自定义类>
这个类型必须有拷贝赋值运算符。这就意味着这个类型不能有任何虚函数或虚基类,以及必须使用编译器创建的拷贝赋值操作。自定义类型中所有的基类和非静态数据成员也都需要支持拷贝赋值操作。一个UDT类型对象可以使用memcpy()进行拷贝,还要确定其对象可以使用memcmp()对位进行比较。
对于特化的版本
内存顺序Memory Order
所谓的memory order,其实就是限制编译器以及CPU对单线程当中的指令执行顺序进行重排的程度(此外还包括对cache的控制方法)。这种限制,决定了以atom操作为基准点(边界),对其之前的内存访问命令,以及之后的内存访问命令,能够在多大的范围内自由重排(或者反过来,需要施加多大的保序限制)。从而形成了6种模式。它本身与多线程无关,是限制的单一线程当中指令执行顺序。
其他参考
relaxed, acquire, release, consume, acq_rel, seq_cst
它们表示的是三种内存模型:
-
sequential consistent(memory_order_seq_cst),
-
relaxed(memory_order_relaxed).
-
acquire release(memory_order_consume, memory_order_acquire, memory_order_release, memory_order_acq_rel),
memory_order_relaxed: 只保证当前操作的原子性,不考虑线程间的同步,其他线程可能读到新值,也可能读到旧值,只保证操作的原子性和内存一致性。
在单个线程内,所有原子操作是顺序进行的。按照什么顺序?基本上就是代码顺序(sequenced-before)。这就是唯一的限制了!两个来自不同线程的原子操作是什么顺序?两个字:任意。
memory_order_release:(可以理解为 mutex 的 unlock 操作)
- 对写入施加 release 语义(store),在代码中这条语句前面的所有读写操作都无法被重排到这个操作之后,即 store-store 不能重排为 store-store, load-store 也无法重排为 store-load
- 当前线程内的所有写操作,对于其他对这个原子变量进行 acquire 的线程可见
- 当前线程内的与这块内存有关的所有写操作,对于其他对这个原子变量进行 consume 的线程可见
memory_order_acquire: (可以理解为 mutex 的 lock 操作)
- 对读取施加 acquire 语义(load),在代码中这条语句后面所有读写操作都无法重排到这个操作之前,即 load-store 不能重排为 store-load, load-load 也无法重排为 load-load
- 在这个原子变量上施加 release 语义的操作发生之后,acquire 可以保证读到所有在 release 前发生的写入。
memory_order_consume:
- 对当前要读取的内存施加 release 语义(store),在代码中这条语句后面所有与这块内存有关的读写操作都无法被重排到这个操作之前
- 在这个原子变量上施加 release 语义的操作发生之后,consume 可以保证读到所有在 release 前发生的并且与这块内存有关的写入,举个例子:
memory_order_acq_rel:
- 对读取和写入施加 acquire-release 语义,无法被重排
- 可以看见其他线程施加 release 语义的所有写入,同时自己的 release 结束后所有写入对其他施加 acquire 语义的线程可见
memory_order_seq_cst:(顺序一致性)
- 如果是读取就是 acquire 语义,如果是写入就是 release 语义,如果是读取+写入就是 acquire-release 语义
- 同时会对所有使用此 memory order 的原子操作进行同步,所有线程看到的内存操作的顺序都是一样的,就像单个线程在执行所有线程的指令一样
内存栅栏Memory Barrier:无锁(lock-free)数据结构的设计中,指令的乱序执行会造成无法预测的行为。所以我们通常引入内存栅栏(Memory Barrier)这一概念来解决可能存在的并发问题。
内存顺序
如下图所示,对于原子变量x,y,z
顺序类型 | 说明与举例 | 可操作类型 |
---|---|---|
relaxed | 只保证单个线程如线程1中对A的操作,如A0、A3、A5的顺序性或者线程2中对x的操作如B2、B3、B5操作的顺序性 | 都可以 |
acquire | 不允许load操作后所有变量的操作被重排到load之前,比如不允许将线程2的B4的y操作重排到B3操作之前,但是在B3之后可以 | load\RMW |
consume | 不允许load操作后的依赖于当前原子变量的变量的操作被重排到load之前,比如线程2中z_x的操作B1不可以被重排到B3之后,但是不依赖于x的y变量的操作B4就可以被移动到x的B3操作之前 | load\RMW |
release | 不允许store操作前所有变量的操作被重排到store之后,比如不允许将线程1的A2、A1操作重排到A3操作之后,但是可以在A3之前 | store\RMW |
acq_rel | 不允许跨越RMW操作重排,即只能在其左右两端进行重排 | RMW |
seq_cst | 所有操作的前后语句不能跨越该操作进行重排,所有线程语句以全局内存修改顺序为参照 | 都可以 |
happens-before
happens-before关系表示的不同线程之间的操作先后顺序。如果A happens-before B,则A的内存状态将在B操作执行之前就可见
synchronizes-with
synchronizes-with关系强调的是变量被修改之后的传播关系(propagate), 即如果一个线程修改某变量的之后的结果能被其它线程可见,那么就是满足synchronizes-with关系的。synchronizes-with可以被认为是跨线程间的happends-before关系
Carries dependency
同一个线程内,表达式A sequenced-before 表达式B,并且表达式B的值是受表达式A的影响的一种关系, 称之为"Carries dependency"
内存模型
顺序连贯(sequential consistency, SC);SC有两点要求:
- 在每个处理器内,维护每个处理器的程序次序;在所有处理器间,维护单一的表征所有操作的次序。
- 对于写操作W1, W2, 不能出现从处理器 P1 看来,执行次序为 W1->W2; 从处理器 P2 看来,执行次序却为 W2->W1 这种情况
缓存一致性协议(cache coherence protocols)
维护写原子性(maintaining write atomicity):
- 要求1:针对同一地址的写操作被串行化(serialized). 图4阐述了对这个条件的需求:如果对 A 的写操作不是序列化的,那么 P3 和 P4 输出(寄存器 1,2)的结果将会不同,这违反了次序一致性
- 对一个新写的值的读操作,必须要等待所有(别的)缓存对该写操作都返回确认通知后才进行
为获得好的性能,我们可以引入放松内存一致性模型(relaxed memory consistency models),这些模型主要通过两种方式优化程序(读写):
放松对程序次序的要求:这种放松与此前所述的“在无缓存的体系结构中采用的优化”类似,仅适用于对不同地址的操作对(opeartion pairs)间使用。
放松对写原子性的要求:一些模型允许读操作在“一个写操作未对所有处理器可见”前返回值。这种放松仅适用于基于缓存的体系结构。
并发存在的问题
数据争用、兵乓缓存、伪共享
超额认购(oversubscription):如果有很多额外线程,就会有很多线程准备执行,而且数量远远大于可 用处理器的数量,不过操作系统就会忙于在任务间切换,以确保每个任务都有时间运行
Amdahl定律:当程序“串行”部分的时间用fs来表示,那么性能增益§就可以通过处理器数量(N)进行估计:
并发分离关注,可以将一个很长的任务交给一个全新的线程,并且留下一个专用的 GUI线程来处理这些事件。