在Linux中每一条线程在内存中对应了一个堆栈,我们没调用一个函数的时候,其实就是在内存中执行压入堆栈的操作。
而多线程就是在内存中有多个堆栈。每个堆栈的大小都是固定的,事先在内存中分配好的。当数据压入堆栈的时候,堆栈的顶部就会延伸,直到延伸到边缘。
在Linux多线程程序编写的时候,很多时候会遇到这样的报错:stack overflow。它指的就是当前线程分配的堆栈空间已经用完了,内存中堆栈的顶部已经超过了边缘。
解决这种问题的方法有很多,这里就不在赘述。这篇文章主要对Linux的线程同步进行讲解。
Linux多线程一个很头疼的问题就是资源竞争的问题。因为线程和进程不同,一般通过fork产生的子进程会将程序数据进行拷贝,而多个线程之间会共享主线程的资源。所以在Linux多线程编程中就会产生这样的资源竞争的问题。最典型的资源竞争问题就是火车售票:
while(1){ if(count>0){ count--; }else{ break; } }
上面这段伪代码就表示了一个售票窗口做的事情,首先是一个循环,重复执行售票的操作。售票之前需要先判断剩余票数是否大于0,如果还有,就需要售出一张,如果没有了,那么必须退出循环,不能再进行卖票了。
如果只有一个窗口执行这样的卖票操作工作就会很顺利,但是那么多的顾客来买票,只有一个窗口是不够的。为了缓解压力,需要开很多个窗口,也就是需要多个线程执行这段程序。这个时候多线程的问题就凸现出来了。当一个窗口进行余票检测完毕,另一个窗口刚好售出一张票,这个时候前一个窗口进行售票,是在它第一次检测到票数的基础上进行减,那么就会多卖出一张票。最后导致的问题就是火车发车的时候很多人拿着相同ID号的票上了火车。
要想解决这个问题,做法就是将票数的改变这一操作变成原子操作,也就是在售票的过程中,每个线程不能互相干扰。这样进行的动作就是多线程的同步。
在Linux中,实现多线程同步的方法有多种,这里主要介绍三种:
1、互斥锁
2、条件变量
3、信号量
1、互斥锁
互斥锁提供了对共享资源的保护访问。在使用的时候一般有三个步骤,以及它们对应的操作函数:
1)初始化互斥锁 pthread_mutex_init
2)获取/释放互斥锁 pthread_mutex_lock / pthread_mutex_unlock
3)销毁互斥锁 pthread_mutex_destroy
互斥锁的使用方法很简单,使用之前需要初始化。使用的是时候将获取/释放函数写在共享资源前后,最后进行锁的销毁。下面就选择这个过程中的一些细节进行讲解:
初始化:
互斥锁的初始化分为静态初始化和动态初始化。
我们通常使用的pthread_mutex_init()方法就是动态初始化,其实互斥锁在Linux内核中就是一个结构体的数据类型。动态初始化的做法就是malloc一段栈内存来保存数据,因为是手动开辟的,所以最后需要调用pthread_mutex_destroy方法来销毁这段栈内存。
静态分配的写法是pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER,PTHREAD_MUTEX_INITIALIZER是一个宏定义,在一般的Linux系统中他的值是一个结构体:{{0. 0. 0. 0. 0. 0, {0, 0}}},使用这种方式初始化的互斥锁最后并不需要调用destroy方法来释放。
静态初始化和动态初始化在使用上并没有什么区别,唯一的区别就是在使用完毕后的释放上,动态初始化的互斥锁需要调用pthread_mutex_destroy方法来释放。
获取/释放互斥锁:
这个过程中提一下容易混淆的两个函数pthread_mutex_lock / pthread_mutex_trylock。顾名思义,前者是锁、后者是尝试锁。在线程没有获取到锁的情况下,这两个函数的操作结果是一样的,就是获取一把锁;而如果线程先前已经获取了锁,那么lock函数就会阻塞住线程,而trylock会直接返回错误,是非阻塞的,不会阻塞住当前线程。和非阻塞的read系统调用类似,一般我们将这类非阻塞的调用和while连用。
我们用互斥锁最常用的就是下面这种情况
pthread_mutex_lock(&lock);
count++;
pthread_mutex_unlock(&lock);
我们总是理解为互斥锁是对一些变量进行加锁,其实并不是这样的,我们看下面这段代码:
#include
#include
#include
pthread_mutex_t lock;
void test(){
pthread_mutex_lock(&lock);
printf("thread test\n");
pthread_mutex_unlock(&lock);
}
int main(void){
pthread_t t;
pthread_mutex_init(&lock, NULL); /** 初始化互斥锁 **/
pthread_mutex_lock(&lock); /** 获取锁 **/
printf("Main lock\n");
pthread_create(&t, NULL, test, NULL); /** 新建线程执行test() **/
sleep(2);
pthread_mutex_unlock(&lock); /** 释放锁 **/
printf("Main unlock\n");
pthread_mutex_destroy(&lock); /** 销毁锁 **/
return 0;
}
在主函数首先动态初始化互斥锁,然后主线程立即获取lock锁,接着开启一个新的线程打印“pthread_test”,睡眠2秒之后进行释放锁。
打印:
Main lock
Main unlock
pthread_test
可以看到打印结果并不是pthread_test穿插在中间。因为pthread_test需要等待lock这把锁释放了之后才能重新获取。这个例子就证明了其实活吃锁保护的并不是变量,而是一段程序。
2、条件变量
条件变量能够实现互斥锁保护共享资源的功能,不过他还是常用在 条件阻塞/条件唤醒这种场合下。意思就是一个线程不满足一定的条件就会阻塞不运行下去,当某个条件满足的时候,当前线程活着其他线程能够去唤醒被阻塞的线程继续运作。
条件变量的使用主要借住四个函数:
pthread_cond_init 初始化
pthread_cond_signal / pthread_cond_wait 唤醒/阻塞
pthread_cond_destroy 销毁
单单看着几个函数肯定能发现条件变量的使用非常简单,不过难点在于条件变量需要搭配互斥锁使用才能实现功能。一般使用格式为:
线程1:
pthread_mutex_lock(&lock);
pthread_cond_wait(&cond, &lock);
pthread_mutex_unlock(&lock);
…
pthread_mutex_unlock(&lock);
线程2:
if( xxx ){
pthread_cond_signal(&cond);
}
在线程1中阻塞住,当线程2满足了一定条件时,再唤醒线程1。
这里要注意,当调用pthread_cond_wait的时候需要配合互斥锁一起使用。条件变量cond是一个结构体数据,在调用wait函数的时候,回去更改这个数据结构的内容,而这个wait函数本身并不是一个原子操作。当多个线程使用同一个条件变量,需要阻塞的时候,都去调用这个wait函数,那么很可能同时去改变这个条件变量结构体数据内容,如果不加入同步机制很可能就会引发问题。所以在调用这个函数之前需要获取一把互斥锁。这个pthread_cond_wait函数其实内部做了三个操作:
1、释放互斥锁
2、等待唤醒
3、加上互斥锁
我们在函数之前获取了互斥锁,其他线程就不会同时去更改条件变量,直到一个线程阻塞成功,这个时候需要释放掉这个互斥锁,供其他线程可以阻塞。然后阻塞的线程就等待signal函数来唤醒它。等待唤醒过后再将互斥锁加上,执行一段被保护的代码后外面再释放互斥锁。
另外值得提的一点就是pthread_mutex_signal函数只可以唤醒条件变量所在的一个线程,至于是哪条线程是由系统调度决定的。如果有的情况需要唤醒这个条件变量对应的所有线程,就可以调用pthread_cond_broadcast函数。
这里给出一个信号量使用的例子:
#include
#include
pthread_mutex_t mut = PTHREAD_MUTEX_INITIALIZER; /** 初始化互斥锁 **/
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; /** 初始化条件变量 **/
void test(){
pthread_mutex_lock(&mut);
pthread_cond_wait(&cond, &mut); /** 线程阻塞,等待唤醒 **/
printf("thread test\n");
pthread_mutex_unlock(&mut);
pthread_exit(NULL);
}
int main(void){
pthread_t t;
int i;
pthread_create(&t, NULL, test, NULL); /** 新建线程,执行test() **/
/** 间隔1秒 打印i **/
for(i=0; i<10; i++){
printf("%d\n", i);
if(i == 4){ /** i==4 唤醒线程 **/
pthread_cond_signal(&cond);
}
sleep(1);
}
return 0;
}
函数功能就是主函数打印1-10,并且开启一个线程打印”pthread test”。线程是阻塞的,当主线程打印到4的时候就唤醒线程。
3、信号量
信号量和条件变量的功能相似,既可以用来保护一段共享资源,也可以用来 条件阻塞/条件唤醒。这里的信号量和Linux常说的信号不同,信号量是Linux线程同步的机制,而信号则是另一个概念。
在Linux中,信号量有两种:POSIX信号量和SystemV信号量。
在使用上,POSIX信号量比较方便,理解起来也很简单,他常用在Linux线程同步。而SystemV信号量较为复杂,提供了一个信号量集合,常用在进程/线程间的同步。这里就简单介绍一下POSIX信号量:
sem_init 初始化
sem_post / sem_wait 唤醒/阻塞
sem_destroy 销毁
可以通过上面的函数看得出来,POSIX信号量和条件变量非常像。它们唯一的区别就是以上四个函数都是原子操作的函数,也就是说他们在使用的时候 不需要配合互斥锁来使用。
这里给出一个POSIX信号量使用的简单例子:
#include
#include
#include
#include
sem_t sem_id;
void test(){
sem_wait(&sem_id);
printf("thread test\n");
pthread_exit(NULL);
}
int main(void){
pthread_t t;
int i;
sem_init(&sem_id, 0, 0); /** 初始化信号量 **/
pthread_create(&t, NULL, test, NULL); /** 新建线程,执行test() **/
/** 间隔1秒 打印i **/
for(i=0; i<10; i++){
printf("%d\n", i);
if(i == 4){ /** i==4 唤醒线程 **/
sem_post(&sem_id);
}
sleep(1);
}
pthread_join(&t, NULL);
sem_destroy(&sem_id);
return 0;
}
这个例子实现了上面条件变量的功能。
在Linux的POSIX信号量中,还分无名信号量和有名信号量。信号量是一个结构体数据类型,把这个数据存储在内存中的时候,就是无名信号量;当把这个数据存储在文件中的时候,就是有名信号量。以上例子中和我们提到的函数都是无名信号量,也比较常用。无名信号量和有名信号量使用上的区别只是初始化和销毁函数不同,sem_init/sem_destroy是无名信号量的,sem_open/sem_close是有名信号量的。有名信号量因为是保存文件的原因,一般可以用它来进行进程同步。