(2020.11.03 Tues)
并发Concurrency
并发指的是在单个系统里同时执行多个独立的活动。单核计算机中,同一时刻只能真正执行一个任务,通过任务切换(task switch),看起来像是任务在并行发生,这样的系统仍然被称为并发(concurrency),因为任务切换的太快,以至于无法分辨任务在何时被挂起而切换到另一个任务,任务通过分时(time-sharing)的方式使用计算核心。切换任务的过程被称作上下文切换(context switch)。
当计算机有多个核心,可以真正实现了并行运行超过一个任务,在物理层面实现并发,称为硬件并发(hardware concurrency)。
并发与并行(parallel)的对比
多进程并发:进程间的保护机制使得进程间通信设置复杂,速度慢。运行多个进程的故有开销大,比如启动进程需要时间,操作系统须投入内部资源来管理进程。
但因为同样的原因,进程间的并发比线程并发更加安全。充分利用了多核特点。
多线程并发:轻量级,每个线程像是轻量级的进程,相互独立运行,分别运行不同的指令序列。共享相同的地址空间,比如程序段、栈和全局数据。使用多线程的开销远小于进程并发。开发者必须确保每个线程访问时所看到的数据是一致的。
多线程并发只在一个核上。
线程是有限的资源,线程越多就会消耗更多的系统资源。程序创建一个新的线程,必须为这个线程创建一个新的栈,每个栈对应一个线程。当某个栈执行到全部弹出时,对应线程完成任务。多线程的进程在内存中有多个栈,栈间用空白区域隔开,以备栈的增长,而任何一个空白区域被填满都可能导致栈溢出的问题。对于有限内存的系统来说这是个问题,需要控制线程数量。
运行越多的线程,系统需要做越多的context switch。每次switch都需要耗费时间,有时候增加一个线程实际上会降低而非提高程序的整体性能。
总的来说,多线程thread交流成本 < 多进程process交流成本。
- 进程描述符记录每个线程的相关信息,i.e.,状态和进度。
- 系统需要把适当的计算时间分配给进程,内核调度器在分配计算时间时,必须把各个线程考虑在内。
- 进程空间必须有多个栈。栈记录着函数调用的顺序,最下方的栈是唯一一个激活函数。多线程中有多个函数处于激活状态,并同时运行。下面是多线程程序。
#include
#include
#include // for sleep
void *func1(void)
{
int i;
for (i = 0; i < 5; i++)
{
printf('func1 is running %d\n',i);
sleep(1);
}
return NULL;
}
void *func2(void)
{
int i;
for (i = 0; i < 5; i++)
{
printf('func2 is running %d\n',i);
sleep(1);
}
return NULL;
}
void *func3(void)
{
int i;
for (i = 0; i < 3; i++)
{
printf('func3 is running %d\n',i);
sleep(1);
}
return NULL;
}
int main()
{
int i=0, ret = 0;
pthread_t func1_id, func2_id, func3_id;
ret = pthread_create(&func1_id,NULL, (void *)func1, NULL);
if (ret)
{
printf('cannot create func1.\n')
return 1;
}
ret = pthread_create(&func2_id,NULL, (void *)func2, NULL);
if (ret)
{
printf('cannot create func2.\n')
return 1;
}
ret = pthread_create(&func3_id,NULL, (void *)func3, NULL);
if (ret)
{
printf('cannot create func3.\n')
return 1;
}
//wait for func3
pthread_join(func3_id, NULL);
printf('Main thread exists.\n');
return 0;
}
另一个并发的代码例子。
#include
#include
void hello()
{
std::cout <<'hello concurrent world\n';
}
int main()
{
std::thread t(hello);
t.join();
}
竞态条件Race condition
多个任务可以共享数据,特别是可以同时修改某个数据时,就有可能发生竞态条件。在并发系统中,如果运行结果依赖于不同线程执行的先后顺序,也会造成竞态条件。
多线程同步Synchronisation
同步,指的是在一个时间内只允许某一个任务访问某个资源。同步可以解决竞态条件问题。
多线程同步就在一定的时间内只允许某一个线程访问某个资源,可通过互斥锁(mutex, mutual exclusion)、条件变量(condition variable)和读写锁(reader-writer lock)来同步资源。下面详细介绍同步中常用的三种方法。
互斥锁Mutex
(2022.06.22 Wed 调整)
mutex是一个特殊变量,mutex是和共享的、被访问的数据结构挂钩。一般被设置为全局变量,它有锁上和解锁两个状态。打开的mutex可由某个线程获得。线程一旦获得某共享数据结构,该结构对应的mutex就会锁上,只有该线程有权打开。其余线程想使用该数据结构即该mutex,只能等到下一次mutex打开。线程结束对该数据的使用,则解锁mutex。每个线程必须遵守上述规则才能保证mutex发挥作用。如果有线程不获得互斥锁而直接修改变量/数据结构,则mutex失去了保护意义。其效力在于多线程共同遵守规则,它本身并不能硬性阻止线程对变量的修改。总之mutex需要开发者写出完善的程序来发挥其作用,其他同步方式也是一样。
while (1) {
mutex_lock(mu); /*无限循环*/
if (i != 0) i = i-1;
else {
printf('no more tickets');
exit();
}
mutex_unlock(mu); /*释放mutex*/
}
另一个case
#include
#include
#include
std::list some_list; //全局变量
std::mutex some_mutex; //全局守护
void add_to_list(in new_value)
{
std::lock_guard guard(some_mutex);
some_list.push_back(new_value);
}
bool list_contains(int value_to_find)
{
std::lock_guard guard(some_mutex);
return std::find(some_list.begin(), some_list.end(), value_to_find) != some_list.end();
}
//使用了lock_guard意味着这两个函数中的访问是互斥的
互斥锁的死锁问题(deadlock)
(2022.06.22 Wed 调整)
mutex针对不同的数据结构,在访问某个数据结构之前,用对应的mutex锁定该数据结构,数据访问结束后对应的mutex解锁该数据结构。可以想见,线程间共享的资源如果有多个不同的数据,则可能有多个mutex。
死锁是多个线程需要锁定两个或更多mutex以执行操作时的最大问题。比如线程1锁定了变量A,并等待变量B,而线程2锁定了变量B,并等待变量A。两个线程均不会放弃各自已经持有的mutex。
解决方案:
- 如果线程需要获取多个mutex,则在每个线程中以相同的顺序获取他们。
- 可以避免嵌套锁,也就是一个线程一旦获得一个mutex,就不再获取mutex。也就是只使用一个mutex
- 在持有mutex时,避免调用用户提供的代码,因为代码中可能包含对其他mutex的调用
- 使用锁层次
条件变量condition variable
常常被保存为全局变量,与mutex合作。条件变量特别适用于多个线程共同等待某个条件发生的情况。这种情况下也可以使用mutex,但是每个进程就需要不断尝试获得mutex并检查条件是否发生,浪费了系统资源。
mutex_lock(mu);
num = num +1; //该工人建造房间。因前一步已经锁定mu,所以在unlock之前num都不会被别的线程改变。
if (num <= 10) { //该工人是前10个完成的
cond_wait(mu, cond);
//该函数做两件事,1释放mutex mu,2等待条件变量cond的通知。符合条件的线程开始等待 ,
//当“第10个房间已经 建好”的通知到达,cond_wait()会再次锁上mu。线程恢复运行,
//执行下一句drink beer指令。而从这里到mutex_unlock(),就构成了 另一个mutex结构。
printf('drink beer');
}
else if (num = 11) {
cond_broadcast(mu, cond);
}
mutex_unlock(mu);
读写锁
与mutex相似,只是对读写做出了区分。当共享资源只有读取而没有写入,则多个任务可以同时读取,不会存在race condition。一旦有线程开始写入,其他读写该资源的进程都要等待。包含了两把锁,读锁(R)和写锁(W)。
R锁控制读取。多个进程(线程?)可以同时读取同一资源。W锁控制写入,同一时间只能有一个线程获得W锁。不过在获得W锁之前,线程必须等待所有持有共享资源R锁的线程释放掉各自的R锁,以免自己的写入操作干扰到其他线程的R。
Reference
1 Vamei, 周梓昕著,树莓派开始玩转Linux,中国工信出版集团,电子工业出版社
2 Anthony Williams著,周全等译,C++并发编程实战(C++ Concurrency in Action),人民邮电出版社