C++对多线程并发的支持(上)

C++对多线程并发的⽀持(上)
⽬录
1、并发介绍
2、任务和线程
3、传递参数
4、返回结果
6、等待事件
7、通信任务
前⾔:
本⽂翻译⾃ C++ 之⽗Bjarne Stroustrup 的 C++ 之旅( A Tour of C++ )⼀书的第 13 章Concurrency。
作者⽤短短数⼗页,带你⼀窥现代 C++ 对并发/多线程的⽀持。原⽂地址:现代 C++ 对多线程/并发的⽀持(上) -- 节选⾃ C++ 之⽗的《 A Tour of C++ 》⽔平有限,有条件的建议直接阅读原版书籍。
1、并发介绍
并发,即同时执⾏多个任务,常⽤来提⾼吞吐量(通过利⽤多处理器进⾏同⼀个计算)或者改善响应性(等待回复的时候,允许程序的其他部分继续执⾏)。所有现代语⾔都⽀持并发。C++ 标准库提供了可移植、类型安全的并发⽀持,经过 20 多年的发展,⼏乎被所有现代硬件所⽀持。标准库提供的主要是系统级的并发⽀持,⽽⾮复杂的、更⾼层次的并发模型;其他库可以基于标准库,提供更⾼级别的并发⽀持。
C++ 提供了适当的内存模型(memory model)和⼀组原⼦操作(atomic operation),以⽀持在同⼀地址空间内并发执⾏多个线程。原⼦操作使得⽆锁编程成为可能。内存模型保证了在避免数据竞争(data races,不受控地同时访问可变数据)的前提下,⼀切按照预期⼯作。
本章将给出标准库对并发的主要⽀持⽰例:thread、mutex、lock()、packaged_task 以及future。这些特征直接基于操作系统构建,相较于操作系统原⽣⽀持,不会带来性能损失,也不保证会有显著的性能提升。
那为什么要⽤标准库⽽⾮操作系统的并发?可移植性。
不要把并发当作灵丹妙药:如果顺序执⾏可以搞定,通常顺序会⽐并发更简单、更快速!
2、任务和线程
塑料标签如果⼀个计算有可能(potentially)和另⼀个计算并发执⾏,我们称之为任务(task)。线程是任务的系统级表⽰。任务可以通过构造⼀个std::thread 来启动,任务作为参数。
任务是⼀个函数或者函数对象。
任务是⼀个函数或者函数对象。
任务是⼀个函数或者函数对象。
void f();              // 函数
struct F {            // 函数对象
void operator()()  // F 的调⽤操作符
};
void user()
{
thread t1 {f};    // f() 在另⼀个线程中执⾏
thread t2 {F()};  // F()() 在另⼀个线程中执⾏
t1.join();  // 等待 t1
t2.join();  // 等待 t2
}
join() 确保线程完成后才退出user(),“join 线程”的意思是“等待线程结束”。
⼀个程序的线程共享同⼀地址空间。线程不同于进程,进程通常不直接共享数据。线程间可以通过共享对象(shared object)通信,这类通信⼀般⽤锁或其他机制控制,以避免数据竞争。
编写并发任务可能会⾮常棘⼿,假如上述例⼦中的 f 和 F 实现如下:
void f() {cout << "Hello ";}
struct F {
void operator()() {cout << "Parallel World!\n";}
};
这⾥有个严重的错误:f 和 F() 都⽤到了 cout 对象,却没有任何形式的同步。这会导致输出的结果不可预测,多次执⾏的结果可能会得到不同的结果:因为两个任务的执⾏顺序是未定义的。程序可能产⽣诡异的输出,⽐如:
PaHerallllel o World!
定义⼀个并发程序中的任务时,我们的⽬标是保持任务之间完全独⽴。最简单的⽅法就是把并发任务看作是⼀个恰巧可以和调⽤者同时运⾏的函数:我们只要传递参数、取回结果,保证该过程中没有使⽤共享数据(没有数据竞争)即可。
3、传递参数
⼀般来说,任务需要处理⼀些数据。我们可以通过参数传递数据(或者数据的指针或引⽤)。
void f(vector<double>& v); // 处理 v 的函数
struct F {                // 处理 v 的函数对象
vector<double>& v;
F(vector<double>& vv) : v(vv) {}
void operator()();
};
int main()
{
vector<double> some_vec{1,2,3,4,5,6,7,8,9};
vector<double> vec2{10,11,12,13,14};
thread t1{f,ref(some_vec)}; // f(some_vec) 在另⼀个线程中执⾏
thread t2{F{vec2}};        // F{vec2}() 在另⼀个线程中执⾏
t1.join();
t2.join();
}
F{vec2} 在 F 中保存了参数vector 的引⽤。F 现在可以使⽤这个vector。但愿在 F 执⾏时,没有其他任务访问 vec2。如果通过值传递 vec2 则可以消除这个隐患。
t1 通过 {f,ref(some_vec)} 初始化,⽤到了thread 的可变参数模板构造,可以接受任意序列的参数。ref() 是来⾃<functional> 的类型函数。为了让可变参数模板把some_vec 当作⼀个引⽤⽽⾮对象,ref() 不能省略。编译器检查第⼀个参数可以通过其后⾯的参数调⽤,并构建必要的函数对象,传递给线程。如果 F::operator()() 和 f() 执⾏了相同的算法,两个任务的处理⼏乎是等同的:两种情况下,都各⾃构建了⼀个函数对象,让thread 去执⾏。
可变参数模板需要⽤ ref()、cref() 传递引⽤
4、返回结果
3 的例⼦中,我传了⼀个⾮const 的引⽤。只有在希望任务修改引⽤数据时我才这么做。这是⼀种很常见的获取返回结果的⽅式,但这么做并不能清晰、明确地向他⼈传达你的意图。稍好⼀点的⽅式是通过const 引⽤传递输⼊数据,通过另外单独的参数传递储存结果的指针。
void f(const vector<double>& v, double *res); // 从 v 获取输⼊; 结果存⼊ *res
class F {
public:
F(const vector<double>& vv, double *p) : v(vv), res(p) {}
void operator()();  // 结果保存到 *res
private:
const vector<double>& v;  // 输⼊源
double *p;                // 输出地址
};
int main()
{
vector<double> some_vec;
vector<double> vec2;
double res1;
double res2;
thread t1{f,cref(some_vec),&res1}; // f(some_vec,&res1) 在另⼀个线程中执⾏
thread t2{F{vec2,&res2}};          // F{vec2,&res2}() 在另⼀个线程中执⾏
t1.join();
t2.join();
}
这么做没问题,也很常见。但我不觉得通过参数传递返回结果有多优雅,我会在 13.7.1 节再次讨论这个话题。
通过参数(出参)传递结果并不优雅
5、共享数据
有时任务需要共享数据,这种情况下,对共享数据的访问需要进⾏同步,同⼀时刻只能有⼀个任务访问数据(但是多任务同时读取不变量是没有问题的)。我们要考虑如何保证在同⼀时刻最多只有⼀个任务能够访问⼀组对象。
解决这个问题需要通过mutex(mutual exclusion object,互斥对象)。thread 通过lock() 获取mutex:
int shared_data;
mutex m;          // ⽤于控制 shared_data 的 mutex
void f()
{
unique_lock<mutex> lck{m};  // 获取 mutex
shared_data += 7;          // 操作共享数据
}  // 离开 f() 作⽤域,隐式⾃动释放 mutex
unique_lock的构造函数通过调⽤m.lock() 获取mutex。如果另⼀个线程已经获取这个mutex,当前线程等待(阻塞)直到另⼀个线程(通过 m.unlock( ) )释放该mutex。当mutex 释放,等待该mutex 的线程恢复执⾏(唤醒)。互斥、锁在 <mutex> 头⽂件中。共享数据和mutex 之间的关联需要⾃⾏约定:程序员需要知道哪个 mutex 对应哪个数据。这样很容易出错,但是我们可以通过⼀些⽅式使得他们之间的关联更清晰明确:
class Record {
public:
mutex rm;
};
不难猜到,对于⼀个Record 对象rec,在访问rec 其他数据之前,你应该先获取。最好通过注释或者良好的命名让读者清楚地知道mutex 和数据的关联。
有时执⾏某些操作需要同时访问多个资源,有可能导致死锁。例如,thread1 已经获取了mutex1,然
后尝试获取mutex2;与此同时,thread2 已经获取mutex2,尝试获取mutex1。在这种情况下,两个任务都⽆法进⾏下去。为解决这⼀问题,标准库⽀持同时获取多个锁:
void f()
{描图纸
unique_lock<mutex> lck1{m1,defer_lock};  // defer_lock:不⽴即获取 mutex
unique_lock<mutex> lck2{m2,defer_lock};
unique_lock<mutex> lck3{m3,defer_lock};
lock(lck1,lck2,lck3);                    // 尝试获取所有锁
// 操作共享数据
}  // 离开 f() 作⽤域,隐式⾃动释放所有 mutexes
lock() 只有在获取参数⾥所有的mutex 之后才会继续执⾏,并且在其持有mutex 期间,不会阻塞(go to sleep)。每个
unique_lock 的析构会确保离开作⽤域时,⾃动释放所有的mutex。
通过共享数据通信是相对底层的操作。编程⼈员要设计⼀套机制,弄清楚哪些任务完成了哪些⼯作,还有哪些未完成。从这个⾓度看,使⽤共享数据不如直接调⽤函数、返回结果。另⼀⽅⾯,有些⼈认为共享数据⽐拷贝参数和返回值效率更⾼。这个观点可能在涉及⼤量数据的时候成⽴,但是locking 和unlocking 也是相对耗时的操作。不仅如此,现代计算机很擅长拷贝数据,尤其是像vector 这种元素连续存储的结构。所以,不要仅仅因为“效率”⽽选⽤共享数据进⾏通信,除⾮你真正实际测量过。
6、等待事件
有时线程需要等待外部事件,⽐如另⼀个线程完成了任务或者经过了⼀段时间。最简单的事件是时间。借助 <chrono>,可以写出:
using namespace std::chrono;
auto t0 = high_resolution_clock::now();
this_thread::sleep_for(milliseconds{20});
auto t1 = high_resolution_clock::now();
cout << duration_cast<nanoseconds>(t1-t0).count() << " nanoseconds passed\n";
注意,我甚⾄没有启动⼀个线程;默认情况下,this_thread 指当前唯⼀的线程。我⽤duration_cast 把时间单位转成了我想要的nanoseconds。
condition_variable 提供了对通过外部事件通信的⽀持,允许⼀个线程等待另⼀个线程,⽐如等待另⼀个线程(完成某个⼯作,然后)触发⼀个事件/条件。
condition_variable ⽀持很多优雅、⾼效的共享形式,但也可能会很棘⼿。考虑⼀个经典的⽣产者-消费者例⼦,两个线程通过⼀个队列传递消息:
class Message { /**/ }; // 通信的对象
queue<Message> q;      // 消息队列
condition_variable cv;  // 传递事件的变量
mutex m;                // locking 机制
queue、condition_variable 以及 mutex 由标准库提供。
消费者读取并处理Message
苟仲武
void consumer()
{
while(true){
水下作业unique_lock<mutex> lck{m}; // 获取 mutex m
cv.wait(lck);              // 先释放 lck,等待事件/条件唤醒
// 唤醒时再次重新获得 lck
auto m = q.front();        // 从队列中取出 Message m
q.pop();
lck.unlock();              // 后续处理消息不再操作队列 q,提前释放 lck
// 处理 m
}
抑制贴
}
这⾥我显式地⽤ unique_lock<mutex>保护queue 和 condition_variable 上的操作。condition_variable 上的cv.wait(lck)会释放参数中的锁lck,直到等待结束(队列⾮空),然后再次获取 lck。
相应的⽣产者代码:
void producer()
{
while(true) {
Message m;
// 填充 m
unique_lock<mutex> lck{m}; // 保护操作
q.push(m);
} // 作⽤域结束⾃动释放锁
}
到⽬前为⽌,不论是thread、mutex、lock 还是condition_variable,都还是低层次的抽象。接下来我们马上就能看到 C++ 对并发的⾼级抽象⽀持。
7、通信任务
标准库还在头⽂件 <future>中提供了⼀些机制,能够让程序员在更⾼的任务的概念层次上⼯作,⽽不是直接使⽤低层的线程、锁:
future 和promise:⽤于从另⼀个线程中返回⼀个值
packaged_task:帮助启动任务,封装了future 和promise,并且建⽴两者之间的关联
async():像调⽤⼀个函数那样启动⼀个任务。形式最简单,但也最强⼤!
钢管扩口机到此这篇关于C++ 对多线程/并发的⽀持(上)的⽂章就介绍到这了,更多相关C++ 对多线程并发的⽀持
内容请搜索以前的⽂章或继续浏览下⾯的相关⽂章希望⼤家以后多多⽀持!

本文发布于:2024-09-23 11:14:22,感谢您对本站的认可!

本文链接:https://www.17tex.com/tex/2/166612.html

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系,我们将在24小时内删除。

标签:数据   任务   线程   共享   等待   参数
留言与评论(共有 0 条评论)
   
验证码:
Copyright ©2019-2024 Comsenz Inc.Powered by © 易纺专利技术学习网 豫ICP备2022007602号 豫公网安备41160202000603 站长QQ:729038198 关于我们 投诉建议