目录
Concurrency
What and Why?
大型软件通常需要同时处理很多任务。
单核环境下,多任务可以同时执行,因此,一个任务不会完全被阻塞而没有响应。
多核环境下,多任务可以在多个不同的核上同时运行,可以提高软件性能。
Multiple processes & Multiple threads
进程(process):指计算机中已运行的程序。
- 每个进程拥有独立的内存空间,开销大
- 进程间通信比较复杂
- 开发难度较大
- 一个进程crash,不影响其他进程
线程(thread):操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运行单位。
- 多个线程共享同样的内存空间,开销小
- 线程间通信也复杂,但是相对简单些
- 开发难度也大,但是相对简单些
- 一个线程crash,整个进程都会crash
何时不适合并发?
如果并发的代价大于并发带来的好处时,不要实现并发。
并发的代价:
- 并发会消耗更多的资源
- 并发会引发更多的线程上下文切换
- 代码复杂的增加,调试分析难度加大
Multi-threading in C++
Launch a Thread
启动一个线程,将参数传递给一个线程函数:
#include <thread>
void do_a_task(int x) {
// do something
};
std::thread my_thread(do_a_task, 1); //参数:函数名,传递进函数的参数
class my_task{
public:
void operator()()const{
// do something
}
};
my_task f;
std::thread my_thread(f); //参数:实例对象
等待线程结束:
my_thread.join();
//如果线程结束了,就直接往下运行
//如果线程还在运行,就会等待线程结束后,join()才退出
my_thread销毁时,线程也将被终止并销毁。
对象与线程分离:detach()
将线程与主线程分离,使该线程运行结束后得以终止自己并释放资源。
分离后,my_thread销毁时,线程将不会被销毁,其将在后台独自运行直至主动退出。
分离后的线程将不能被pthread_join等待。
my_thread.detach();
pthread
使用pthread的线程管理。
启动线程:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routione)(void*), void *arg);
//参数:线程id,线程属性
#include <pthread.h>
void* do_a_task(void* param) {
// do something
pthread_exit(NULL); //也可以直接returen
};
pthread_t thread_id;
int rc = pthread_create(&thread_id, NULL, do_a_task, NULL);
等待线程结束:
pthread_join(thread_id, &status); //返回值将传到地址中
分离线程:
pthread_detach(thread_id); //分离后id与线程则不再绑定
销毁线程:
pthread_cancel(thread_id);
async
启动异步线程并行完成独立工作。
std::future 一个对象模板
std::async 一个方法,能创建future对象
future::get() 将等待异步线程完成计算并获得返回结果
#include <future>
#include <iostream>
int find_the_answer() {
int answer;
// do task 1
return answer;
}
int main() {
std::future<int> the_answer = std::async(find_the_answer);
// do task 2
std::cout << "The answer is" << the answer.get() << std::endl;
}
//若大量启动异步线,消耗会非常大
Data Sharing & Race Conditions
Problems with sharing data
vector不是线程安全的。
多个线程同时往一个vector中放入数据时,可能导致数据访问冲突,从而导致数据丢失,甚至崩溃。
举例:
//以下代码无法正常运行
#include <vector>
#include <thread>
#include <iostream>
using namespace std;
vector<int> data;
void put_data(int count) {
for(int i = 0; i < count; ++i) {
data.push_back(i);
}
}
int main() {
thread t1(put_data, 10000);
thread t2(put_data, 10000);
t1.join();
t2.join();
cout << "Total number of data:" << data.size() << endl;
return 0;
}
对于这个问题,我的解法是加锁,加锁的话,估计效率比较低,不知道是否还有更好的解法。
#include <vector>
#include <thread>
#include <mutex>
#include <iostream>
using namespace std;
vector<int> data;
mutex mtx;
void put_data(int count) {
mtx.lock();
for(int i = 0; i < count; ++i) {
data.push_back(i);
}
mtx.unlock();
}
int main() {
thread t1(put_data, 10000);
thread t2(put_data, 10000);
t1.join();
t2.join();
cout << "Total number of data:" << data.size() << endl;
return 0;
}
输出:Total number of data:20000
Race conditions
举例:
#include <thread>
#include <iostream>
#include <string>
using namespace std;
string x;
string y;
void swap_data(int count) {
for(int i = 0; i < count; ++i) {
string c = x;
x = y;
y = c;
}
}
int main() {
for(int i = 0; i < 100; ++i) {
x = "0";
y = "10000";
thread t1(swap_data, 100000);
thread t2(swap_data, 100000);
t1.join();
t2.join();
cout << "x is " << x << ". y is " << y << endl;
}
return 0;
}
可能产生的情况:
提问:代码的运行结果是什么?
- 以下四种的一种:
x is 0. y is 10000
x is 10000. y is 0
x is 0. y is 0
x is 10000. y is 10000- 除了以上四种还有其他可能
- Crash
- 运行结果正常
答案:2
若把上述代码中的x与y改为int类型,执行结果如下:
How to avoid?
解决方案一
保护数据,避免对共享数据同时进行操作
加锁 -> 进行存在数据访问冲突的操作 -> 释放锁
使得存在冲突的操作成为原子操作。
原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束,中间不会有任何context switch(切换到另一个线程)
解决方案二
重新设计数据结构,并发模式,实现无锁方案。
实现难度较大,但是并发性能会更好。
Mutex lock
使用mutex进行加锁。
定义锁:
#include <mutex>
using namespace std;
mutex mtx;
加锁:
mtx.lock();
释放锁:
mtx.unlock();
举例:
#include <mutex>
using namespace std;
mutex mtx;
void process_data() {
mtx.lock();
// do something
mtx.unlock();
}
使用mutex不要忘记释放锁。
lock_guard封装,在构造函数里加锁,析构函数中释放锁,避免遗忘释放锁的情况。
std::lock_guard<std::mutex> guard(mtx);
潜在的问题:不能及时释放锁,因其只能在对象销毁时释放锁,持有锁的时间可能过长,影响并发性能
更灵活的封装unique_lock,可以不在构造函数中加锁,后续可以重复加锁和释放锁。
//在构造函数中加锁
std::unique_lock<std::mutex> unique(mtx);
//在构造函数中先不加锁
std::unique_lock<std::mutex> unique(mtx, std::defer_lock);
Problems with locking
- 问题一:并发度降低
- 加锁的代码无法进行实现并发,将串行执行
- 性能受到影响
应尽量减少持有锁的时间
- 问题二:死锁
如何避免死锁?
- 保证每个线程的加锁顺序一致
- 一次性加所有的锁
- 最多加一个锁
recursive lock
同一个线程对同一个锁加两次也会导致死锁。
std::mutex mtx;
mtx.lock();
mtx.lock();
可以使用recursive_mutex mtx来替代mutex,但是其性能受一定影响。
std::recursive_mutex mtx;
mtx.lock();
mtx.lock();
// do something
mtx.unlock();
mtx.unlock();
Concurrent Operation Synchronization
数据处理流水线:
问题:Thread2如何知道queue中有数据,需要进行后续处理?
- 死循环去检查queue中是否有数据,但是CPU很高,浪费CPU资源,对性能影响很大
- 两次检查之间sleep很短的时间,但是实时性差,在sleep期间没有办法干活,浪费资源,性能也受影响
- 事件通知
Condition variable
条件变量
申明:
std::condition_variable data_cond;
唤醒一个线程:
data_cond.notify_one();
唤醒所有线程:
data_cond.notify_all();
等待通知,无条件的唤醒:
std::unique_lock unique(mtx);
data_cond.wait(unique);
//等待过程中是不会加锁的,通知后才加锁
有条件的唤醒:
std::unique_lock unique(mtx);
data_cond.wait(unique, []{return i == 1;});
超时等待:
std::unique_lock unique(mtx);
data_cond.wait(unique, std::chrono::seconds(5));
//通常情况下最好都是超时等待
举例:
std::condition_variable data_cond;
std::mutex mtx;
void put_data() {
// put the data into queue
data_cond.notify_one();
}
void get_data() {
std::unique_lock unique(mtx);
data_cond.wait(unique);
// get the data from the queue
}
Concurrency Design
Thread-safe data structures
not thread_safe : vector、string、queue、map、…
多线程访问的时候会有数据竞争,导致结果错误甚至崩溃。
为了保证并发没有问题,开发人员都需要对共享访问的对象进行加锁。
对特定数据结构,设计和实现thread-safe的数据结构,比如:共享内存。
Lock-based
类似数据库中基于锁的并发控制
- 通过加锁解决数据竞争问题
- 减少不必要的加锁
- 及时释放锁,尽量缩短持有锁的时间
- 注意死锁,如果可能避免多个锁
- 如果可能尽量使用atomic types(原子类型),或者借鉴使用成熟的代码
Lock-free
类似数据库中乐观/多版本并发控制
- 需要精心设计每一个操作,尽量避免数据竞争
- 如果数据竞争不可避免,需要有冲突检测机制,并在检测到冲突时,重新做
Lock-free性能通常更好,但是开发难度大很多。
Data sharing
数据分区
在各个线程启动之前,把数据分区,每个线程处理一个分区的数据。
Task division
数据处理按任务划分
将数据处理划分为多个子任务,每个任务启动一个/多个线程处理,处理完之后将数据交给下个线程进行下个任务的数据处理,形成数据处理流水线并发。
Factors affecting performance
在性能方面,并发设计需要考虑的因素:
- 系统资源,包括CPU,内存、网络带宽、磁盘访问速度等
- 数据竞争频率
- 缓存数据设计,减少缓存数据的频繁切换
- 高并发带来的额外负载,比如线程切换、内存消耗等
- 开发难度
Thread pool
线程池:创建若干个可执行的线程放入一个池(容器)中,有任务需要处理时,会提交到线程中的任务队列,处理完之后线程并不会被销毁,而是仍然在线程池中等待下一个任务。
作用:
- 控制系统中线程的总数
- 减少线程启动停止带来的额外系统开销
- 简化各个模块的线程管理
Testing and Debugging
Concurrency-related bugs
并发可能导致的主要问题:
- 卡死
- 死锁
- 忘了释放锁
- 并未卡死,只是特别慢
- 数据竞争导致崩溃
- 在某种特定的并发执行顺序下,越界访问、访问已释放内存等等
- 数据竞争导致不确定的错误结果
- 在某种特定的并发执行顺序下,访问了脏数据,导致结果错误
并发问题的主要特点:
- 不定期地发生,甚至难以再现
- 没有规律的错误结果,难以分析原因
- 不常出现,但是一出现就比较致命
如何解决?
- 尽可能地收集并发问题发生时的各种信息
- 日志
- crash dump
- 按步骤对问题进行分析
- 再现问题
- 分析问题根源
- 提出解决方案
- 验证解决方案
再现问题的重要性:
- 问题再现有利于分析问题
- 问题再现才能验证解决方案是否有效
- 不再现问题就分析解决问题,可能导致所有工作都是徒劳的
如何再现问题?
- 搭建测试环境,再现问题
- 若无法再见,则需要进行分析或者获得更多的错误信息来调整测试,并最终再现问题
- 可以通过添加sleep等,制造产生问题的并发执行顺序,从而再现问题
- 简化测试环境,使其在更短的时间,更简单的环境喜爱再现问题,找到问题根源
- 如果可能添加单元测试再现问题
分析问题根源:
- debug分析问题
- 代码分析
- 添加日志协助分析
- 合理修改代码制造再现问题的并发执行顺序
- 在合适的位置添加sleep
提出解决方案:
- 加锁
- 降低并发度,异步操作改为同步操作
- 等等具体问题具体分析
验证解决方案的重要性:
- 验证解决方案的有效性,否则不但没有解决问题,反而会制造更多bug
如何验证解决方案?
- 单元测试
- 没有解决方案之前,单元测试无法通过
- 有解决方案之后,单元测试通过
- 在再现问题的测试环境下反复测试验证,确保问题不再发生
Summary
- 并发概念
- C++多线程编程
- 线程间数据共享与竞争
- 线程间的操作同步
- 并发设计
- 测试与调试分析
推荐书籍:C++ Concurrency in Action
Practice
运行上述代码,可以得知show函数一直不停地在刷新屏幕,并且一直占有CPU,这样效率很低,请修改上述代码, 让show函数仅在input更新x之后再刷新屏幕并将refresh计数加1,确保update count与refresh count一致,CPU不能占有100%;input线程退出后,通知show线程退出,然后整个程序正常退出。
#include <unistd.h>
#include <ncurses.h>
#include <sys/ioctl.h>
#include <string>
#include <thread>
using namespace std;
string x = "Hello world!";
int updateCount = 0, refreshCount = 0;
void input() {
char chr;
while(read(fileno(stdin), &chr, 1) == 1) {
switch (chr){
case 'q':
x = "Input thread terminated!";
updateCount++;
return;
case 'h':
x = "Hello world!";
updateCount++;
break;
case 'w':
x = "Welcome to C++!";
updateCount++;
break;
default:;
}
}
}
void show() {
while (true) {
refreshCount++;
mvwprintw(stdscr, 0, 0, "string value is: %s", x.c_str());
mvwprintw(stdscr, 1, 0, "update count is: %d", updateCount);
mvwprintw(stdscr, 2, 0, "refresh count is: %d", refreshCount);
wrefresh(stdscr);
}
}
int main() {
initscr(); noecho(); curs_set(FALSE);
thread t1(input), t2(show);
t1.join(); t2.join();
nocbreak(); endwin();
return 0;
}
解决方法:对于show()函数进行有条件的唤醒,为其添加condition_variable。
int fomerUpdateCount = updateCount;
//在所有updateCount++前添加代码fomerUpdateCount = updateCount;j记录updateCount的变化
mutex mtx;
std::condition_variable data_cond;
//在show()函数内使用锁和条件变量
std::unique_lock unique(mtx);
data_cond.wait(unique, []{return updateCount > fomerUpdateCount;});