目录
线程等待
-
线程分为主线程和新线程。
-
只有主线程退出才标志着进程结束。而主线程退出会强制该进程的所有线程退出。
-
线程退出也需要像进程退出一样被等待,因为主线程需要知道新线程的任务完成的怎么样。而且不等待就可能会出现内存泄漏,所以需要回收资源。
-
等待线程的函数叫做pthread_join。
-
第一个参数就是线程的tid。
-
第二个参数是为了获取线程执行函数的返回值void*。因此需要二级指针。
-
pthread_join函数只需要获取新线程退出的退出码,不需要获取信号编号。因为一旦某个线程是由于信号退出,那么整个进程都会挂掉,此时退出的信号编号毫无意义。信号是为进程设置的。
-
所以线程退出的时候,我们默认线程是正常退出。一旦异常,那么就是进程的事了。
线程退出
-
新线程可以像函数一样使用return退出。
-
exit使得进程退出,如果在某个线程中使用,那么整个进程都会退出。
-
我们还有两个其他的线程退出函数。
-
如果我们想要只退出某个线程不影响其他线程,除了return还有pthread_exit函数,
-
pthread的参数就是线程函数的退出码。
-
但是上面的两个方法都是新线程主动退出,我们如果想要在线程外部让它退出呢?我们有pthread_cancel函数,
-
参数就是tid。
-
使用这个函数可以在一个线程中去干掉另外的线程。
pthread_join和退出码:
- pthread_join获取的退出码和使用的退出方式有关。return退出,会获取return的值。使用pthread_exit退出,会获取该函数的参数。使用pthread_cancel退出,会获取一个宏PTHREAD_CANCEL,该宏是-1。如果不关心退出码,可以设空。
线程分离
- 新线程结束的时候需要被join,不然可能会造成资源泄漏。但是join就会拖累主线程,我们可以使用线程分离将新线程分离出去。这样新线程结束的时候自己释放资源,就不会拖累别人。
- 我们可以在主线程中分离新线程,也可以在新线程中自己分离自己。
- 即使线程分离,该线程异常退出,仍然会导致进程退出。
互斥锁mutex
- 可能被多个线程使用的资源叫做临界资源。
- 访问临界资源的代码叫做临界区。
- 而临界区的操作应该是原子的,只能有完成和未完成两种状态,不能有中间状态。不然有可能造成数据错误。
- 我们使用mutex锁来帮助我们解决这个问题。
- 锁的粒度越小越好,因为锁会影响多线程的效率。
- 锁也是一种临界资源,所以锁应该也具有原子性。
- 使用pthread_mutex_init来初始化锁,使用pthread_mutex_destroy来销毁锁。因为锁也是一种资源,需要被回收。
- 使用pthread_mutex_lock来上锁,使用pthread_mutex_unlock来解锁。
1 #include <stdio.h>
2 #include <pthread.h>
3 #include <unistd.h>
4 #include <sys/types.h>
5 #include <sys/stat.h>
6 #include <fcntl.h>
7
8 int ticket = 100;
9 pthread_mutex_t lock; //定义锁
10
11 void* buy_ticket(void* arg){
12 sleep(5);
13 while(1){
14 pthread_mutex_lock(&lock); //上锁
15 if(ticket > 0){
16 printf("thread no %d, get a ticket no %d\n", (int)arg, ticket);
17 ticket--;
18 pthread_mutex_unlock(&lock); //解锁
19 usleep(10000);
20
21 }
22 else{
23 pthread_mutex_unlock(&lock); //不能在break后面解锁,不然可能死锁
24 break;
25 }
26 }
27 }
28
29 #define SIZE 10
30 int main(){
31 pthread_t thread[SIZE];
32 pthread_mutex_init(&lock, NULL); //初始化锁
33 for(int i = 0; i < SIZE; ++i){ //创建多线程
34 pthread_create(thread + i, NULL, buy_ticket, (void*)i);
35 }
36
37
38 for(int i = 0; i < SIZE; ++i){
39 pthread_join(thread[i], NULL); //多线程的等待
40 }
41
42 pthread_mutex_destroy(&lock); //锁使用完毕,释放锁资源
43 return 0;
44 }
mutex的总结
- mutex可以理解为一个结构体,
struct mutex{
int lock; //有0和1两种状态,表示上锁和解锁
wait_queue* head; //如果已经上锁,则将该线程加入等待队列;
// ...
}
- 上面我们所说的4个对互斥锁的操作函数,可以理解为对这个结构体的操作。init就是初始化锁,给定lock和head的初始化数据。destroy就是销毁这个结构体资源。lock就是加锁操作,将lock设为0,如果此时还有线程来访问,就将其加入等待队列。unlock就是将lock设为1,清空等待队列。
mutex的原理:
-
对于临界资源我们需要进行保护,即对临界区增加锁。但是想要加锁就必须加同一把锁,否则是无意义的,这样锁就变成了临界资源,锁想要保护临界区,但是需要先保护自己。那么锁是如何实现的呢?
-
mutex会在全局区开辟一块空间表示锁的状态,即结构体中的lock,init会将lock初始化为1,可以理解为mutex此时就是1。这块全局区对所有线程都是共有的。
-
上面的伪代码说明了上锁的过程:一个线程上锁的时候,先将该线程的%al寄存器变成0,然后交换寄存器的0和内存中的mutex的1,注意,为了实现锁,汇编指令xchgb虽然实现很复杂,但是却是一条指令,这样保证了交换的原子性。
此时在判断的时候如果寄存器是0,就表示需要被阻塞等待,如果是1,那就上锁,然后访问临界资源。 -
至于解锁,直接将当前线程的mutex变成1即可(因为能被解锁的线程一定是加过锁的)。
mutex会降低多线程的效率
- 一方面就是锁会将并行变成串行,而这与多线程背道而驰,所以会降低。
- 另外一方面,考虑这样一种情况,如果一个线程已经上锁,在解锁前就被切换走了,那么会发生什么呢?这会不会造成什么危害呢?
- 如果新的线程想访问这一段被锁着的资源,可能吗?不可能,此时新线程只能被阻塞,即使是它的时间片。它只能等待解锁才能够获取临界资源,所以完全不用担心在未解锁的时候线程切换。但是你可能已经意识到了,新的线程占据cpu却什么也干不了,因为资源被锁住了,这样也是浪费了多线程的效率!
- 实际上,除了线程,进程也是一样。多进程想要访问临界资源,同样也需要锁,具体的原理和缺点跟上面多线程的完全类似。
可重入VS线程安全
1.概念
- 线程安全:多个线程并发执行同一段代码不会出现不同结果。常见对全局变量或静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 可重入:同一个函数被不同的执行流执行,当一个执行流还没有结束,其他执行流就再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或任何问题的情况下,我们称之为可重入。反之,称之为不可重入。
2.常见线程不安全的情况
- 不保护共享变量的函数
- 函数状态随着被调用,状态发生变化的函数
- 返回指向静态变量指针的函数
- 调用线程不安全函数的函数
3.常见的线程安全的情况
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
4.常见不可重入的情况
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
5.常见可重入的情况
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都有函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
6.可重入与线程安全的区别与联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
- 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的
常见锁的概念
死锁
- 死锁是指在在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程占用的不会释放的资源而处于一种永久等待的状态。
死锁的四个必要条件
- 互斥条件:一个资源一次只能被一个执行流使用。
- 请求与保持条件:一个执行流因请求资源而等待时,对已申请到的资源保持不放。
- 不剥夺条件:一个执行流已获得的资源,在未使用完之前不能强行剥夺。
- 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系。
死锁的避免
- 破坏死锁的四个条件
- 加锁顺序一致(A和B线程都需要加1和2两种锁,那么加锁的顺序应该一样,即A加1,再加2。B也应该先加1锁,再加2锁。如果不一致就可能死锁。)
- 避免锁未释放的场景
- 资源一次性分配(尽量将需要加锁的资源一次性到位或是写在同一个临界区里面,这样只需要一把锁就可以完成。)
死锁的避免算法(了解)
- 死锁检测算法
- 银行家算法
同步
为什么需要同步
栗子:
我有个只能放1个苹果的盘子,现在要求A和B两个线程互相配合。A线程将苹果放入盘子中。B线程将苹果拿走。此时我们发现盘子变成了临界资源。于是我们对A和B访问盘子的临界区进行上锁保护。
但是有没有这样一种情况,A线程的优先级太高了,使得A运行10000次,才等到B开始运行。这样A访问了锁10000次,但是只进行了一次工作,因为B一直没有将苹果拿走,所以A无法再次放入苹果。这样就是完全的浪费效率。有没有这样一种办法,A如果发现盘子中苹果没有被拿走(条件不满足),就阻塞等待,然后告诉B线程(通知)来拿走苹果,然后再进行工作呢?这就是同步。
A线程放入苹果->A线程发现条件不满足->A线程阻塞等待并且通知B线程->B线程接受通知并被唤醒->B线程执行任务->B线程执行完毕再次通知A线程
同步概念
- 同步就是在保证数据安全的情况下,让线程能够按照某种顺序访问临界资源的手段。
- 同步是为了更好的让多线程协同工作。
实现同步&&条件变量
- 我们发现当某些条件不满足的时候会被唤醒,这里的条件抽象出来就是条件变量。
- pthread_cond_init函数,用来初始化条件变量。第一个参数是条件变量类型的变量,第二个参数是属性信息,可以不用管。
- pthread_cond_destroy函数,用来销毁条件变量的资源。
- 用来等待cond条件满足,并且解锁以便于别的线程工作
- 用来通知一个线程cond条件已经满足。
1 #include <stdio.h>
2 #include <pthread.h>
3 #include <unistd.h>
4 #include <sys/types.h>
5 #include <sys/stat.h>
6 #include <fcntl.h>
7
8
9 pthread_mutex_t lock;
10 pthread_cond_t cond;
11
12 void* thread_func1(void* arg){ //线程1要执行的函数
13 while(1){
14 pthread_cond_wait(&cond, &lock); //每隔
15 printf("wait success.\n"); //等待cond条件满足
16
17 }
18 }
19 void* thread_func2(void* arg){ //线程2要执行的函数
20 while(1){
21 pthread_cond_signal(&cond); //每隔1S通知一次cond变量
22 printf("signal success.\n");
23 sleep(1);
24 }
25 }
26 int main(){
27
28 pthread_t t1, t2;
29
30 pthread_mutex_init(&lock, NULL);
31 pthread_cond_init(&cond, NULL);
32
33 int no1 = 1;
34 int no2 = 2;
35 pthread_create(&t1, NULL, thread_func1, (void*)no1);
36 pthread_create(&t2, NULL, thread_func2, (void*)no2);
37
38 pthread_join(t1, NULL);
39 pthread_join(t2, NULL);
40
41 pthread_mutex_destroy(&lock);
42 pthread_cond_destroy(&cond);
43 return 0;
44 }
如何理解条件变量cond?
条件变量就是某些条件而已,这些条件有满足和未满足两种情况。你可以将cond理解成一个struct cond结构体,而结构体里面有
struct cond{
int _value; // value有0和1, 用来识别条件是否满足
wait_queue* _head; // 等待队列
}