再谈多线程概念
写出的东西是为了给别人看吗,满足自己的虚荣心? 我觉得是整理思路, 边写边帮助思考这个初心最好了。
今天读了一下《并行程序设计导论》这本书pthread多线程的一章,发现好多的概念依然不能达到常识的认知级别。于是又仔细的翻了一遍。这次是带着问题去读的。
首先多线程的编程在并行计算的定位是什么。因为一个进程可以存在很多的线程,同一个进程当中的所有线程可以实现资源共享,所以它是一种共享内存的编程模型。 进程是正在运行程序的一个实例,除了可执行代码外,它还包括 堆段 系统资源描述符(如文件描述符) 安全信息(进程允许访问的硬件和软件的资源信息) 描述进程状态的信息(如进程是否准备运行或者正在等待某个资源,寄存器中的内容)。 线程是类似轻量级进程。
对于不同的语言提供了相应的 线程初始化 创建 运行 停止 销毁等接口
当多个线程同时在运行,都要访问共享变量或共享文件的时候,若至少其中的一个是更新操作,那么这些访问就会导致错误,我们称之为竞态条件。而一系列的连续代码就是 临界区。
当多个线程想要进入临界区时。必须要有些手段能够控制两个以上的线程同时访问临界区。一种最为原始的方法是 忙等的方式,具体参考《并行计算导论》4.5章节 。 使用一个标志变量,在一个循环上测试这个变量是否满足条件。因为这个变量又是全局的,必须保证进入临界区才可以对这个变量进行更改。说白了。要把所有线程的临界区都变成串行执行的代码才不会出什么问题。所耗费的时间也应该是所有线程临界区执行时间的总和。这种忙等的方式实现临界区,必须保证编译器背后不会搞鬼,即不会做什么优化---有些代码一个线程执行时候语句是顺序无关的,但是多个线程执行就会出问题,编译器无法检测这样的代码放弃优化。另一方面,这种忙等的方式一直占据cpu,浪费硬件资源。
如何解决上述提到的两个问题呢,于是就有了 互斥量的概念。通过声明一个全局的互斥量,可以保证每次进入临界区的只有一个线程,如果一个线程试图获得该互斥量,这个互斥量已经被其他线程所获得,当前线程只能等待,与忙等不同的是,这种是线程暂时被挂起,不消耗太多cpu资源,因此解决了上述编译优化以及忙等的问题。
通常调用类似 pthread_mutex_lock()返回时叫做获得了锁,而调用 pthread_mutex_unlock() 称为释放了一个锁或互斥量。
之前一直对互斥量和信号量两个概念分不清楚。为什么有了互斥量还要引入信号量的概念,有些费解。其实信号量与互斥量非常不同。 互斥量是由线程所有,哪个线程获得它,哪个线程就有释放他的责任,不能由任何其他的线程干预。信号量则不是,信号量能够被所有的线程去改变状态,线程通过更新信号量的值来传达当前一个状态,告知其他线程做出相应的反应。更进一步说,信号量可以明确指示唤醒哪一个在信号量上等待的线程可以继续执行, 而互斥量只能告知我有没有进入临界区执行代码。 信号量也根本不存在临界区的概念。
实际上我总是试图找一个像样的理由来说明信号量存在的必要性,上面的说法总是让人感觉还有没有说透的秘密,就是信号量这样的意义是不是在设计同步程序的时候会更加容易理解很多呢。没有设计过很多的程序,所以我根本回答不了这个问题。但是我所读的书当中确实给出了一个例子。我能忍住这种漏出一些真相的秘密暂时不被戳破,期望后续学习中能够有机缘领悟它。
举个例子,是一个能够通知其他线程消息的一段代码。
...
pthread_mutex_lock(mutex[dest]);
...
messages[dest]=my_msg;
pthread_mutex_unlock(mutex[dest]);
pthread_mutex_lock(mutex[myrank]);
printf("(Thread id = %d) %s ",myrank,message[myrank])
这是用互斥锁实现的,但是这个程序是有问题的,你能一下子看出哪里有问题吗。你可能会想,我们最暴力的方式是把所有可能的执行方式全都试一遍就找到会出现的问题了。我相信存在某种组块记忆的方式让你快速简洁发现问题,但是可能需要像象棋大师那样研究成千上万的残局棋谱,请后续的事实告诉我是否有必要花精力这样去做。话说回来,这段代码有什么问题吗。问题出在打印message[myrank] 这一句,message[myrank]可能空指针。因为你可以保证互斥访问这个变量,但是不能保证你在访问他的时候他已经被初始化了。问题在于myrank这个线程执行的太快了。当还没执行message[myrank]=my_msg,已经打印它。
从这个例子可以看出,互斥量不是告知这个变量可不可用,而是有没有人在用。可不可用似乎是这个变量的一个状态,与线程的关系不大。这里信号量的作用就显现出来了,如果用信号量来表示的话,可能更容易解决。那我们尝试用信号量来写一下:
...
message[dest]=my_msg;
...
pthread_cond_signal(message[dest]);
pthread_cond_wait(message[myrank]);
printf("(Thread id = %d) %s ",myrank,message[myrank])
问题是解决了,但是message[myrank]这个变量是不是互斥访问的呢,信号量初始化是多少吗,这些取决于你的设计,在这里不会出现,因为这里的信号量是二元的,这里设计只有一个线程从pthread_cond_wait返回。我现在还没有找到一个通用的设计方案可以快速正确的设计出这样信号量同步的程序。应该需要很多的经验吧。或者具体问题具体分析。
上面的问题可能已经体现了,比如初始化的时候,我们可能期望等待所有的线程到达同一个点的时候能够阻塞,或者需要计算多线程的运行时间需要有一个统一开始的基准时间。我们叫这个同步的点 路障。多线程程序调试起来并不容易,路障也为程序的调试提供了方便。
下面我们来考虑一下路障的实现。
首先路障的同步点必须等到所有的线程都到达这个地方的时候才能继续运行,基本的逻辑就出来了,需要一个全局的计数器 ,每次有一个新的线程到达路障,判断一下,如果计数器小于线程的总数,在当前区域阻塞,如果计数器等于线程总数,那么释放所有的阻塞点。互斥量实际上就是维护了这样一个线程计数器。
int counter ;
int thread_count;
pthread_mutex_t barrier_mutex ;
void Threadwork(){
pthread_mutex_lock(barrier_mutex);
counter++;
pthread_mutex_unlock(barrier_mutex);
while(counter < thread_count);
}
我们可能很快意识到这里有忙等的问题。那能不能把等待这个东西给去掉。先来看一看信号量如何实现路障。别着急,先来想一想,信号量起到一个控制状态的作用,而且任意线程都可以对它进行修改。还不存在临界区的概念。如何用来设置路障呢。再来考虑一下路障,我们仍然要借助计数器的方式。
int counter =0;
sem_t count_sem;
sem_t barrier;
void Threadwork(...){
sem_wait(count_sem);
if(counter == thread_count - 1){
counter =0;
sem_post(counter_sum);
for(j=0;j < thread_count -1 ; j++){
sem_post(barrier);
}
}else{
counter++;
sem_post(&counter_sem);
sem_wait(&barrier_sem);
}
}
上面的算法是怎么想出来,遵循两个点,一个是把路障的执行串行化,这一个利用的是count_sem这个信号量,但是当进入线程的路障数目等于thread_count的时候,唤醒所有路障等待。 所有路障的等待是由信号量barrier_sem 实现的。
我们回忆一下我们的需求,就会所有的线程在等待某个时间发生才会继续执行下去,这个条件触发等待事件。上面的实现实在是不够具体而且难以理解。对于费劲理解的东西仍然难以记住,那么可能就是有些问题了,我们需要改进它并相信一定有更浅显易懂的实现。如果我们把等待的条件声明成一个结构体变量,还有一个在这个条件上等待的函数和一个唤醒等待的函数,这样会不会出现更简洁的实现呢。唤醒等待容易理解,无非就是发个触发的信号就可以了。但是什么是在一个条件变量上等待呢。等待是个状态对吧,那么在这个状态上的线程有没有要注意的地方呢。我们不妨先把路障用互斥锁保护起来,每次只能有一个线程在路障里面。如果一旦阻塞在某个线程上,该线程挂起。这个时候,我们必须允许其他的线程进入路障进行操作,要不然就死锁了。所以在条件上等待必须包含暂时释放锁的操作。等到被满足的相应条件被唤醒的时候,我们仍然在互斥锁保护的区域内,接下去很可能还会继续修改路障访问的全局变量,所以必须 重新获得锁,在走出路障的时候再释放锁。其实这个变量就是 条件变量。好了现在思路有了,我们开始写代码:
/*shared variables */
int counter;
pthread_mutex_t barrier_mutex;
pthread_cond_t cond_barrier;
int thread_count ;
Threadwork(...){
pthread_mutex_lock(&barrier_mutex);
if(counter == thread_count - 1){
counter =0;
pthread_cond_signal(&cond_barrier, &barrier_mutex);
}else{
while(pthread_cond_wait(&con_barrier,&barrier_mutex)!=0);
}
pthread_mutex_unlock(&barrier_mutex);
}
所以条件变量和互斥量结合会达到这样的效果。这样条件变量的作用就显现出来了。至于怎么更好的利用条件变量,感觉自己还是实践的有限,不能给出很好的回答。我也不会强求自己在没有体验过的东西上瞎咧咧。
pthread已经实现了路障的接口。有兴趣自行上网查找吧。
这一章最后提到了一种特殊的锁, 读写锁。一句话,如果有读者读,写者阻塞,如果有写者写,读者写者都阻塞。依据不同的应用的读写频率,读写锁的效率有很大的差异。一个很好的例子是多线程链表。有三种加锁方式,一种对链表整体加锁,一种是对每个节点加锁,最后是利用读写锁。 结果是对每个节点加锁的效率是最低的,cpu耗费大量时间对锁进行管理,不是个好办法,其他两种中用读写锁的效率会好很多。当然这取决于读写操作所占的比例。
你知道多线程还有很多可以研究的地方,比如说死锁,比如多线程的编程模型。后面的学习会涉及这些。