UNIX环境高级编程——第十一章线程
1 概念
1.1什么是线程,察看指定线程的LWP号:ps –Lf pid
- 轻量级的进程(LWP:light weight process),在Linux环境下线程的本质仍是进程。
- 进程:拥有独立的地址空间,拥有PCB,相当于独居。
- 线程:有PCB,但没有独立的地址空间,多个线程共享进程空间,相当于合租。
在Linux操作系统下 - 线程:最小的执行单位
- 进程:最小分配资源单位,可看成是只有一个线程的进程。
线程的特点
- 类Unix系统中,早期是没有“线程”概念的,80年代才引入,借助进程机制实现出了线程的概念。因此在这类系统中,进程和线程关系密切。
- 线程是轻量级进程(light-weight process),也有PCB,创建线程使用的底层函数和进程一样,都是clone
- 从内核里看进程和线程是一样的,都有各自不同的PCB.
- 进程可以蜕变成线程
- 在linux下,线程最是小的执行单位;进程是最小的分配资源单位。
- 线程的执行函数也是一个回调函数,也可以看做是主函数向内核的注册。所以子线程的执行并不一定在注册子线程的时候执行,而要看主线程和子线程对CPU的竞争。
- 线程的所有函数在调用失败时通常会返回错误代码,(之前的其他的函数都是返回-1,并设置全局变量)。这是与其他POSIX函数不同的地方。
实际上,无论是创建进程的fork,还是创建线程的pthread_create,底层实现都是调用同一个内核函数 clone。
- 如果复制对方的地址空间,那么就产出一个“进程”;
- 如果共享对方的地址空间,就产生一个“线程”。
so:Linux内核是不区分进程和线程的, 只在用户层面上进行区分。
所以,线程所有操作函数 pthread_* 是库函数,而非系统调用。
1.2 线程共享资源
- 文件描述符表
- 每种信号的处理方式
- 当前工作目录
- 用户ID和组ID
- 内存地址空间 (.text/.data/.bss/heap/共享库) ,栈不共享,即使是主进程的栈也不共享。
1.3 线程非共享资源
- 线程id
- 处理器现场和栈指针(内核栈)
- 独立的栈空间(用户空间栈)–即在某个线程函数中定义的,这是这个线程独占的,不共享。
- errno变量
- 信号屏蔽字
- 调度优先级
1.4 线程优缺点
优点:
- 提高程序并发性
- 开销小
- 数据通信、共享数据方便
缺点: - 库函数,不稳定
- gdb调试、编写困难
- 对信号支持不好
优点相对突出,缺点均不是硬伤。Linux下由于实现方法导致进程、线程差别不是很大。
2 线程创建-pthread_create函数
函数作用: 创建一个新线程
函数原型int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
返回值:
- 成功,返回0
- 失败,返回错误号
函数参数:
- pthread_t:传出参数,保存系统为我们分配好的线程ID。当前Linux中可理解为:typedef unsigned long int pthread_t。
- attr:通常传NULL,表示使用线程默认属性。若想使用具体属性也可以修改该参数。
- start_routine:函数指针,指向线程主函数(线程体),该函数运行结束,则线程结束。
- arg:线程主函数执行期间所使用的参数。
注意点:
- 由于pthread_create的错误码不保存在errno中,因此不能直接用perror()打印错误信息,可以先用strerror()把错误码转换成错误信息再打印。
- 如果任意一个线程调用了exit或_exit,则整个进程的所有线程都终止,由于从main函数return也相当于调用exit,为了防止新创建的线程还没有得到执行就终止,我们在main函数return之前延时1秒,这只是一种权宜之计,即使主线程等待1秒,内核也不一定会调度新创建的线程执行,下一节我们会看到更好的办法。
- pthread库不是Linux系统默认的库,连接时需要使用静态库libpthread.a,所以在线程函数在编译时,需要连接库函数 gcc pthread.c -o pthread -lpthread
练习题:
1 编写程序创建一个线程,并给线程传递一个结构体参数/(或者一个int)。
//创建子线程: 传递参数
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <pthread.h>
struct Test
{
int data;
char name[64];
};
//线程执行函数
void *mythread(void *arg)
{
//int n = *(int *)arg;
struct Test *p = (struct Test *)arg;
//struct Test *p = arg;
//printf("n==[%d]\n", n);
printf("[%d][%s]\n", p->data, p->name);
printf("child thread, pid==[%d], id==[%ld]\n", getpid(), pthread_self());
}
int main()
{
int n = 99;
struct Test t;
memset(&t, 0x00, sizeof(struct Test));
t.data = 88;
strcpy(t.name, "xiaowen");
//int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
// void *(*start_routine) (void *), void *arg);
//创建子线程
pthread_t thread;
//int ret = pthread_create(&thread, NULL, mythread, &n);
int ret = pthread_create(&thread, NULL, mythread, &t);
if(ret!=0)
{
printf("pthread_create error, [%s]\n", strerror(ret));
return -1;
}
printf("main thread, pid==[%d], id==[%ld]\n", getpid(), pthread_self());
//目的是为了让子线程能够执行起来
sleep(1);
return 0;
}
2 编写程序,主线程循环创建5个子线程,并让子线程判断自己是第几个子线程。
//循环创建子线程,并且打印是第几个子线程
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <pthread.h>
//线程执行函数
void *mythread(void *arg)
{
int i = *(int *)arg;
printf("[%d]:child thread, pid==[%d], id==[%ld]\n", i, getpid(), pthread_self());
sleep(100);
}
int main()
{
//int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
// void *(*start_routine) (void *), void *arg);
//创建子线程
int ret;
int i = 0;
int n = 5;
int arr[5];
pthread_t thread[5];
for(i=0; i<n; i++)
{
arr[i] = i;
ret = pthread_create(&thread[i], NULL, mythread, &arr[i]);
if(ret!=0)
{
printf("pthread_create error, [%s]\n", strerror(ret));
return -1;
}
}
printf("main thread, pid==[%d], id==[%ld]\n", getpid(), pthread_self());
//目的是为了让子线程能够执行起来
sleep(100);
return 0;
}
【注】
- 1 此程序中,子线程和主线程的执行顺序是不确定的,主线程创建子线程后,子线程不一定先执行。
- 2 不能使多个子线程都共享同一块内存空间,应该使每个子线程访问不同的内存空间,可以在主线程定义一个数组:int arr[5];,然后创建线程的时候分别传递不同的数组元素,这样每个子线程访问的就是互不相同的内存空间,这样就可以打印正确的值。
- 3 根据测试程序还可以得出结论:
如果主线程早于子线程退出,则子线程可能得不到执行,因为主线程退出,整个进程空间都会被回收,子线程没有了生存空间,所以也就得不到执行。
线程之间(包含主线程和子线程)可以共享同一变量,包含全局变量或者非全局变量(但是非全局变量必须在其有效的生存期内) - 4 虽然主线程把新线程ID放在了thread[],但是子线程不能安全的访问他,而是应该用pthread_self()获取线程ID。其原因是子线程可能在主线程返回thread[]之前就运行了,这样就会导致子线程看到的是未经初始化的thread[]。
3 线程终止
3.1 pthread_exit函数
在线程中禁止调用exit函数,否则会导致整个进程退出,取而代之的是调用pthread_exit函数,这个函数是使一个线程退出,如果主线程调用pthread_exit函数也不会使整个进程退出,不影响其他线程的执行。
函数描述:将单个线程退出
函数原型:void pthread_exit(void *retval);
函数参数:retval表示线程退出状态,通常传NULL。其他线程可以通过pthread_join函数访问到这个指针。
- 注意,pthread_exit或者return返回的指针(retval)所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了,栈空间就会被回收。(也可以返回指向常量的指针)
3.2 pthread_join函数
函数描述:阻塞等待线程退出,获取线程退出状态。其作用,对应进程中的waitpid() 函数。
函数原型:int pthread_join(pthread_t thread, void **retval);
函数返回值:
- 成功:0;
- 失败:错误号
函数参数: - thread:线程ID
- retval:传出参数,存储线程结束状态,不管命啥名,整个指针和pthread_exit的参数是同一块内存地址。
编写程序,使主线程获取子线程的退出状态。
注:一般先定义void *ptr; 然后pthread_join(threadid, &ptr);
//线程退出函数测试
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <pthread.h>
struct Test
{
int data;
char name[64];
};
int g_var = 9;
struct Test t;
//线程执行函数
void *mythread(void *arg)
{
printf("child thread, pid==[%d], id==[%ld]\n", getpid(), pthread_self());
//printf("[%p]\n", &g_var);
//pthread_exit(&g_var);
memset(&t, 0x00, sizeof(t));
t.data = 99;
strcpy(t.name, "xiaowen");
pthread_exit(&t);
}
int main()
{
//int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
// void *(*start_routine) (void *), void *arg);
//创建子线程
pthread_t thread;
int ret = pthread_create(&thread, NULL, mythread, NULL);
if(ret!=0)
{
printf("pthread_create error, [%s]\n", strerror(ret));
return -1;
}
printf("main thread, pid==[%d], id==[%ld]\n", getpid(), pthread_self());
//回收子线程
void *p = NULL;
pthread_join(thread, &p);
//int n = *(int *)p;
struct Test *pt = (struct Test *)p;
printf("child exit status:[%d],[%s],[%p]\n", pt->data, pt->name, p);
return 0;
}
3.3 pthread_detach函数
线程分离状态:指定该状态,线程主动与主控线程断开关系。线程结束后,其退出状态不由其他线程获取,而直接自己自动释放。网络、多线程服务器常用。
进程若有该机制,将不会产生僵尸进程。僵尸进程的产生主要由于进程死后,大部分资源被释放,一点残留资源仍存于系统中,导致内核认为该进程仍存在。
也可使用 pthread_create函数参2(线程属性)来设置线程分离。pthread_detach函数是在创建线程之后调用的。
函数描述:实现线程分离
函数原型:int pthread_detach(pthread_t thread);
函数返回值
- 成功:0;
- 失败:错误号
一般情况下,线程终止后,其终止状态一直保留到其它线程调用pthread_join获取它的状态为止。但是线程也可以被置为detach状态,这样的线程一旦终止就立刻回收它占用的所有资源,而不保留终止状态。不能对一个已经处于detach状态的线程调用pthread_join,这样的调用将返回EINVAL错误。也就是说,如果已经对一个线程调用了pthread_detach就不能再调用pthread_join了。
练习:编写程序,在创建线程之后设置线程的分离状态。
- 说明:如果线程已经设置了分离状态,则再调用pthread_join就会失败,可用这个方法验证是否已成功设置分离状态。
//设置子线程为分离属性
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <pthread.h>
//线程执行函数
void *mythread(void *arg)
{
printf("child thread, pid==[%d], id==[%ld]\n", getpid(), pthread_self());
sleep(10);
}
int main()
{
//int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
// void *(*start_routine) (void *), void *arg);
//创建子线程
pthread_t thread;
int ret = pthread_create(&thread, NULL, mythread, NULL);
if(ret!=0)
{
printf("pthread_create error, [%s]\n", strerror(ret));
return -1;
}
printf("main thread, pid==[%d], id==[%ld]\n", getpid(), pthread_self());
//设置线程为分离属性
pthread_detach(thread);
//子线程设置分离属性,则pthread_join不再阻塞,立刻返回
ret = pthread_join(thread, NULL);
if(ret!=0)
{
printf("pthread_join error:[%s]\n", strerror(ret));
}
//目的是为了让子线程能够执行起来
sleep(1);
return 0;
}
3.4pthread_cancel函数
函数描述:请求杀死(取消)统一进程的其他线程。其作用,对应进程中 kill() 函数。
函数原型:int pthread_cancel(pthread_t thread);
函数返回值
- 成功:0;
- 失败:错误号
【注意】:线程的取消并不是实时的,而有一定的延时。需要等待线程到达某个取消点(检查点)。
类似于玩游戏存档,必须到达指定的场所(存档点,如:客栈、仓库、城里等)才能存储进度。杀死线程也不是立刻就能完成,必须要到达取消点。
取消点:是线程检查是否被取消,并按请求进行动作的一个位置。通常是一些系统调用creat,open,pause,close,read,write… 执行命令man 7 pthreads可以查看具备这些取消点的系统调用列表。可粗略认为一个系统调用(进入内核)即为一个取消点。还以通过调用pthread_testcancel函数设置一个取消点。
- 函数原型:void pthread_testcancel(void);
练习:编写程序,让主线程取消子线程的执行。
先测试一下没有取消点看看能否使线程取消;然后调用pthread_testcancel设置一个取消点,看看能够使线程取消。
//创建子线程
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <pthread.h>
//线程执行函数
void *mythread(void *arg)
{
while(1)
{
int a;
int b;
//设置取消点
//pthread_testcancel();
printf("-----\n");
}
}
int main()
{
//int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
// void *(*start_routine) (void *), void *arg);
//创建子线程
pthread_t thread;
int ret = pthread_create(&thread, NULL, mythread, NULL);
if(ret!=0)
{
printf("pthread_create error, [%s]\n", strerror(ret));
return -1;
}
printf("main thread, pid==[%d], id==[%ld]\n", getpid(), pthread_self());
//取消子线程
pthread_cancel(thread);
pthread_join(thread, NULL);
return 0;
}
3.5pthread_equal函数
函数描述:比较两个线程ID是否相等。
函数原型:int pthread_equal(pthread_t t1, pthread_t t2);
注意:这个函数是为了以能够扩展使用的, 有可能Linux在未来线程ID pthread_t 类型被修改为结构体实现。
3.6 进程函数和线程函数比较
进程 | 线程 |
---|---|
fork | pthread_creat |
exit | pthread_exit |
wait/waitpid | ptjread_join |
kill | pthread_cancel |
getpid | pthread_self |
4 线程同步
4.1线程同步的概念
线程同步,指一个线程发出某一功能调用时,在没有得到结果之前,该调用不返回。同时其它线程为保证数据一致性,不能调用该功能。
4.2 线程同步的例子
创建两个线程,让两个线程共享一个全局变量int number, 然后让每个线程数5000次数,看最后打印出这个number值是多少?
线程A代码片段:
线程B代码片段:
代码片段说明
- 代码中使用调用usleep是为了让两个子线程能够轮流使用CPU,避免一个子线程在一个时间片内完成5000次数数。
- 对number执行++操作,使用了中间变量cur是为了尽可能的模拟cpu时间片用完而让出cpu的情况。
测试结果
- 经过多次测试最后的结果显示,有可能会出现number值少于5000*2=10000的情况。
分析原因
- 假如子线程A执行完了cur++操作,还没有将cur的值赋值给number失去了cpu的执行权,子线程B得到了cpu执行权,而子线程B最后执行完了number=cur,而后失去了cpu的执行权;此时子线程A又重新得到cpu的执行权,并执行number=cur操作,这样会把线程B刚刚写回number的值被覆盖了,造成number值不符合预期的值。
数据混乱的原因
- 资源共享(独享资源则不会)
- 调度随机(线程操作共享资源的先后顺序不确定)
- 线程间缺乏必要的同步机制。
以上3点中,前两点不能改变,欲提高效率,传递数据,资源必须共享。只要共享资源,就一定会出现竞争。只要存在竞争关系,数据就很容易出现混乱。所以只能从第三点着手解决。使多个线程在访问共享资源的时候,出现互斥。
如何解决问题?
原子操作的概念:原子操作指的是该操作要么不做,要么就完成。
使用互斥锁解决同步问题
使用互斥锁实质是模拟原子操作,互斥锁示意图:
Linux中提供一把互斥锁mutex(也称之为互斥量)。每个线程在对资源操作前都尝试先加锁,成功加锁才能操作,操作结束解锁。
资源还是共享的,线程间也还是竞争的,但通过“锁”就将资源的访问变成互斥操作,而后与时间有关的错误也不会再产生了。
- 线程1访问共享资源的时候要先判断锁是否锁着,如果锁着就阻塞等待;若锁是解开的就将这把锁加锁,此时可以访问共享资源,访问完成后释放锁,这样其他线程就有机会获得锁。
- 应该注意:图中同一时刻,只能有一个线程持有该锁,只要该线程未完成操作就不释放锁。
使用互斥锁之后,两个线程由并行操作变成了串行操作,效率降低了,但是数据不一致的问题得到解决了。
4.3 互斥锁
可以理解为模拟原子操作。
4.3.1 互斥锁相关函数
pthread_mutex_t 类型:互斥变量
- 其本质是一个结构体,为简化理解,应用时可忽略其实现细节,简单当成整数看待。
- pthread_mutex_t mutex; 变量mutex只有两种取值1、0。
pthread_mutex_init函数
函数描述:初始化一个互斥锁(互斥变量) —> 初值可看作1
函数原型:int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
函数参数
- mutex:传出参数,调用时应传 &mutex
- attr:互斥锁属性。是一个传入参数,通常传NULL,选用默认属性(线程间共享)。
- restrict关键字:只用于限制指针,告诉编译器,所有修改该指针指向内存中内容的操作,只能通过本指针完成。不能通过除本指针以外的其他变量或指针修改
返回值:成功返回0,失败返回错误编号。(之前提到过,线程函数不像其他POSIX函数一样失败返回-1且设置全局变量errno)
互斥量mutex的两种初始化方式:
- 静态初始化:如果互斥锁 mutex 是静态分配的(定义在全局,或加了static关键字修饰),可以直接使用宏进行初始化。
pthead_mutex_t muetx = PTHREAD_MUTEX_INITIALIZER; - 动态初始化:局部变量应采用动态初始化。
pthread_mutex_init(&mutex, NULL)
pthread_mutex_destroy函数
函数描述:销毁一个互斥锁
函数原型:int pthread_mutex_destroy(pthread_mutex_t *mutex);
函数参数:mutex—互斥锁变量
返回值:成功返回0,失败返回错误编号。
pthread_mutex_lock函数
函数描述:对互斥所加锁,可理解为将mutex–
函数原型:int pthread_mutex_lock(pthread_mutex_t *mutex);
函数参数:mutex—互斥锁变量
返回值:成功0,失败返回错误编号
pthread_mutex_unlock函数
函数描述:对互斥所解锁,可理解为将mutex ++
函数原型:int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功0,失败返回错误编号
pthread_mutex_trylock函数
函数描述:尝试加锁,用在不希望被阻塞的时候。尝试对互斥量进行加锁,如果调用时互斥量处于未锁住状态,那么此函数将锁住互斥量,直接返回0且不会出现阻塞。如果互斥量已经被锁住,那么不能再次锁定,返回EBUSY。
函数原型:int pthread_mutex_trylock(pthread_mutex_t *mutex);
函数参数:mutex—互斥锁变量
返回值:成功0,失败返回错误编号。
互斥锁(互斥量)的使用步骤
1 创建一把锁:pthread_mutex_t mutex,应该为一全局变量。
2 在mian函数中初始化互斥锁:pthread_mutex_init(&mutex,NULL)
3 锁的使用—在共享资源出现的位置的上下加锁和解锁
Pthread_mutex_lock(&mutex); —可看做mutex=0
Pthread_mutex_unlock(&mutex);—可看做mutex=1
4 在main函数中释放互斥锁。
注意:必须在所有操作共享资源的线程上都加上锁否则不能起到同步的效果。
4.3.2加锁和解锁
- lock尝试加锁,如果加锁不成功,线程阻塞,阻塞到持有该互斥量的其他线程解锁为止。
- unlock主动解锁函数,同时将阻塞在该锁上的所有线程全部唤醒,至于哪个线程先被唤醒,取决于优先级、调度。默认:先阻塞、先唤醒。
4.3.3 练习:使用互斥锁解决两个线程数数不一致的问题。
编写思路:
1 创建一把互斥量(锁):pthread_mutex_t mutex,应该为一全局变量。
2 在mian函数中初始化互斥锁:pthread_mutex_init(&mutex,NULL)
3 锁的使用—在共享资源出现的位置的上下加锁和解锁
Pthread_mutex_lock(&mutex); —可看做mutex=0
Pthread_mutex_unlock(&mutex);—可看做mutex=1
4 在main函数中释放互斥锁。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <pthread.h>
#include <time.h>
//定义一把锁
pthread_mutex_t mutex;
void *mythread1(void *args)
{
while(1)
{
//加锁
pthread_mutex_lock(&mutex);
printf("hello ");
sleep(rand()%3);
printf("world\n");
//解锁
pthread_mutex_unlock(&mutex);
sleep(rand()%3);
}
pthread_exit(NULL);
}
void *mythread2(void *args)
{
while(1)
{
//加锁
pthread_mutex_lock(&mutex);
printf("HELLO ");
sleep(rand()%3);
printf("WORLD\n");
//解锁
pthread_mutex_unlock(&mutex);
sleep(rand()%3);
}
pthread_exit(NULL);
}
int main()
{
int ret;
pthread_t thread1;
pthread_t thread2;
//随机数种子
srand(time(NULL));
//互斥锁初始化
pthread_mutex_init(&mutex, NULL);
ret = pthread_create(&thread1, NULL, mythread1, NULL);
if(ret!=0)
{
printf("pthread_create error, [%s]\n", strerror(ret));
return -1;
}
ret = pthread_create(&thread2, NULL, mythread2, NULL);
if(ret!=0)
{
printf("pthread_create error, [%s]\n", strerror(ret));
return -1;
}
//等待线程结束
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
//释放互斥锁
pthread_mutex_destroy(&mutex);
return 0;
}
总结:
1 在访问共享资源前加锁,访问结束后立即解锁。锁的“粒度”应越小越好。
2 使用互斥锁之后,两个线程由并行变为了串行,效率降低了,但是可以使两个线程同步操作共享资源,从而解决了数据不一致的问题。
4.3.4 死锁
死锁并不是linux提供给用户的一种使用方法,而是由于用户使用互斥锁不当引起的一种现象。
常见的死锁有两种:
- 第一种:自己锁自己,如下图代码片段
- 第二种 线程A拥有A锁,请求获得B锁;线程B拥有B锁,请求获得A锁,这样造成线程A和线程B都不释放自己的锁,而且还想得到对方的锁,从而产生死锁,如下图所示:
如何解决死锁:
- 让线程按照一定的顺序去访问共享资源
- 在访问其他锁的时候,需要先将自己的锁解开
- 调用pthread_mutex_trylock,如果加锁不成功会立刻返回
4.4 读写锁
4.4.1 概念
什么是读写锁
- 读写锁也叫共享-独占锁。当读写锁以读模式锁住时,它是以共享模式锁住的;当它以写模式锁住时,它是以独占模式锁住的。写独占、读共享。有三种模式,分别是读模式下加锁,写模式下加锁,不加锁。
读写锁使用场合
- 读写锁非常适合于对数据结构读的次数远大于写的情况。
读写锁特性
加粗样式- 读写锁是“写模式加锁”时,解锁前,所有对该锁加锁的线程都会被阻塞。
- 读写锁是“读模式加锁”时,如果线程以读模式对其加锁会成功;如果线程以写模式加锁会阻塞。
- 读写锁是“读模式加锁”时, 既有试图以写模式加锁的线程,也有试图以读模式加锁的线程。那么读写锁会阻塞随后的读模式锁请求。优先满足写模式锁。读锁、写锁并行阻塞,写锁优先级高
读写锁场景练习:
- 线程A加写锁成功, 线程B请求读锁:
线程B阻塞,当线程A解锁之后,线程B加锁成功。 - 线程A持有读锁, 线程B请求写锁
线程B阻塞,当线程A解锁之后,线程B加锁成功 - 线程A拥有读锁, 线程B请求读锁
线程B加锁成功 - 线程A持有读锁, 然后线程B请求写锁, 然后线程C请求读锁
B阻塞,c阻塞 - 写的优先级高
A解锁,B线程加写锁成功,C继续阻塞
B解锁,C加读锁成功 - 线程A持有写锁, 然后线程B请求读锁, 然后线程C请求写锁
BC阻塞
A解锁,C加写锁成功,B继续阻塞
C解锁,B加读锁成功
读写锁总结
读并行,写独占,当读写同时等待锁的时候写的优先级高
4.4.2 读写锁主要操作函数
定义一把读写锁:pthread_rwlock_t rwlock;
初始化读写锁:int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, const pthread_rwlockattr_t *restrict attr);
函数参数:
- rwlock-读写锁
- attr-读写锁属性,传NULL为默认属性
销毁读写锁:
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
加读锁:int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
尝试加读锁:int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
加写锁:int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock);
尝试加写锁:int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
解锁:int pthread_rwlock_unlock(&pthread_rwlock_t *rwlock);
练习:3个线程不定时写同一全局资源,5个线程不定时读同一全局资源。
//读写锁测试程序
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <pthread.h>
int number = 0;
//定义一把读写锁
pthread_rwlock_t rwlock;
//写线程回调函数
void *thread_write(void *arg)
{
int i = *(int *)arg;
int cur;
while(1)
{
//加写锁
pthread_rwlock_wrlock(&rwlock);
cur = number;
cur++;
number = cur;
printf("[%d]-W:[%d]\n", i, cur);
//解锁
pthread_rwlock_unlock(&rwlock);
sleep(rand()%3);
}
}
//读线程回调函数
void *thread_read(void *arg)
{
int i = *(int *)arg;
int cur;
while(1)
{
//加读锁
pthread_rwlock_rdlock(&rwlock);
cur = number;
printf("[%d]-R:[%d]\n", i, cur);
//解锁
pthread_rwlock_unlock(&rwlock);
sleep(rand()%3);
}
}
int main()
{
int n = 8;
int i = 0;
int arr[8];
pthread_t thread[8];
//读写锁初始化
pthread_rwlock_init(&rwlock, NULL);
//创建3个写子线程
for(i=0; i<3; i++)
{
arr[i] = i;
pthread_create(&thread[i], NULL, thread_write, &arr[i]);
}
//创建5个读子线程
for(i=3; i<n; i++)
{
arr[i] = i;
pthread_create(&thread[i], NULL, thread_read, &arr[i]);
}
//回收子线程
int j = 0;
for(j=0;j<n; j++)
{
pthread_join(thread[j], NULL);
}
//释放锁
pthread_rwlock_destroy(&rwlock);
return 0;
}
4.5 条件变量
4.5.1 概念
条件变量是线程的另外一种同步机制,这些同步对象为线程提供了会合的场所,理解起来就是两个(或者多个)线程需要碰头(或者说进行交互—一个线程给另外的一个或者多个线程发送消息),我们指定在条件变量这个地方发生,一个线程用于修改这个变量使其满足其它线程继续往下执行的条件,其它线程则接收条件已经发生改变的信号。
条件变量同锁一起使用使得线程可以以一种无竞争的方式等待任意条件的发生。所谓无竞争就是,条件改变这个信号会发送到所有等待这个信号的线程。而不是说一个线程接受到这个消息而其它线程就接收不到了。
条件本身不是锁!但它也可以造成线程阻塞。通常与互斥锁配合使用。给多线程提供一个会合的场所。
- 使用互斥量保护共享数据;
- 使用条件变量可以使线程阻塞, 等待某个条件的发生, 当条件满足的时候解除阻塞.
条件变量的两个动作:
- 条件不满足, 阻塞线程
- 条件满足, 通知阻塞的线程解除阻塞, 开始工作。
4.5.2 条件变量相关函数
定义一个条件变量:pthread_cond_t cond。
初始化条件变量:int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr):
- 函数参数
cond:条件变量
attr:条件变量属性,通常传NULL - 函数返回值,成功0,失败返回错误号
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex):
- 函数描述:条件满足,引起线程阻塞并解锁;条件满足, 解除线程阻塞, 并加锁。
默认阻塞,唤醒一般是由pthread_cond_signal来实现的。 - 函数参数:cond: 条件变量;mutex: 互斥锁变量
- 函数返回值:成功返回0, 失败返回错误号。
int pthread_cond_signal(pthread_cond_t *cond):
- 函数描述: 唤醒至少一个阻塞在该条件变量上的线程
- 函数参数: 条件变量
- 函数返回值: 成功返回0, 失败返回错误号
4.5.3 深入理解条件变量
pthread_cond_wait总和一个互斥锁结合使用。在调用pthread_cond_wait前要先获取锁。pthread_cond_wait函数执行时先自动释放指定的锁,然后等待条件变量的变化。在函数调用返回之前,自动将指定的互斥量重新锁住。
int pthread_cond_signal(pthread_cond_t * cond);
pthread_cond_signal通过条件变量cond发送消息,若多个消息在等待,它只唤醒一个。pthread_cond_broadcast可以唤醒所有。调用pthread_cond_signal后要立刻释放互斥锁(是指在锁种使用这个函数的情况,见下面代码第一种情况),因为pthread_cond_wait的最后一步是要将指定的互斥量重新锁住,如果pthread_cond_signal之后没有释放互斥锁,pthread_cond_wait仍然要阻塞。
无论哪种等待方式,都必须和一个互斥锁配合,以防止多个线程同时请求pthread_cond_wait()(或pthread_cond_timedwait(),下同)的竞争条件(Race Condition)。mutex互斥锁必须是普通锁(PTHREAD_MUTEX_TIMED_NP)或者适应锁 (PTHREAD_MUTEX_ADAPTIVE_NP),且在调用pthread_cond_wait()前必须由本线程加锁 (pthread_mutex_lock()),而在更新条件等待队列以前,mutex保持锁定状态,并在线程挂起进入等待前解锁。在条件满足从而离开 pthread_cond_wait()之前,mutex将被重新加锁,以与进入pthread_cond_wait()前的加锁动作对应。
激发条件有两种形式,pthread_cond_signal()激活一个等待该条件的线程,存在多个等待线程时按入队顺序激活其中一个;而pthread_cond_broadcast()则激活所有等待线程。
下面是另一处说明:给出了函数运行全过程。 为什么在唤醒线程后要重新mutex加锁?
了解 pthread_cond_wait() 的作用非常重要 – 它是 POSIX 线程信号发送系统的核心,也是最难以理解的部分。
首先,让我们考虑以下情况:线程为查看已链接列表而锁定了互斥对象,然而该列表恰巧是空的。这一特定线程什么也干不了 – 其设计意图是从列表中除去节点,但是现在却没有节点。因此,它只能:
锁定互斥对象时,线程将调用 pthread_cond_wait(&mycond,&mymutex)。pthread_cond_wait() 调用相当复杂,因此我们每次只执行它的一个操作。
pthread_cond_wait() 所做的第一件事就是同时对互斥对象解锁(于是其它线程可以修改已链接列表),并等待条件 mycond 发生(这样当 pthread_cond_wait() 接收到另一个线程的“信号”时,它将苏醒)。现在互斥对象已被解锁,其它线程可以访问和修改已链接列表,可能还会添加项。 【要求解锁并阻塞是一个原子操作】
此时,pthread_cond_wait() 调用还未返回。对互斥对象解锁会立即发生,但等待条件 mycond 通常是一个阻塞操作,这意味着线程将睡眠,在它苏醒之前不会消耗 CPU 周期。这正是我们期待发生的情况。线程将一直睡眠,直到特定条件发生,在这期间不会发生任何浪费 CPU 时间的繁忙查询。从线程的角度来看,它只是在等待 pthread_cond_wait() 调用返回。
现在继续说明,假设另一个线程(称作“2 号线程”)锁定了 mymutex 并对已链接列表添加了一项。在对互斥对象解锁之后,2 号线程会立即调用函数 pthread_cond_broadcast(&mycond)。此操作之后,2 号线程将使所有等待 mycond 条件变量的线程立即苏醒。这意味着第一个线程(仍处于 pthread_cond_wait() 调用中)现在将苏醒。
现在,看一下第一个线程发生了什么。您可能会认为在 2 号线程调用 pthread_cond_broadcast(&mymutex) 之后,1 号线程的 pthread_cond_wait() 会立即返回。不是那样!实际上,pthread_cond_wait() 将执行最后一个操作:重新锁定 mymutex。一旦 pthread_cond_wait() 锁定了互斥对象,那么它将返回并允许 1 号线程继续执行。那时,它可以马上检查列表,查看它所感兴趣的更改。
来看一个例子(你是否能理解呢?):
In Thread1:
pthread_mutex_lock(&m_mutex);
pthread_cond_wait(&m_cond,&m_mutex);
pthread_mutex_unlock(&m_mutex);
In Thread2:
pthread_mutex_lock(&m_mutex);
pthread_cond_signal(&m_cond);
pthread_mutex_unlock(&m_mutex);
为什么要与pthread_mutex 一起使用呢? 这是为了应对 线程1在调用pthread_cond_wait()但线程1还没有进入wait cond的状态的时候,此时线程2调用了 cond_singal 的情况。 如果不用mutex锁的话,这个cond_singal就丢失了。加了锁的情况是,线程2必须等到 mutex 被释放(也就是 pthread_cod_wait() 释放锁并进入wait_cond状态 ,此时线程2上锁) 的时候才能调用cond_singal.
pthread_cond_signal即可以放在pthread_mutex_lock和pthread_mutex_unlock之间,也可以放在pthread_mutex_lock和pthread_mutex_unlock之后,但是各有有缺点。
之间:
pthread_mutex_lock
xxxxxxx
pthread_cond_signal
pthread_mutex_unlock
缺点:在某下线程的实现中,会造成等待线程从内核中唤醒(由于cond_signal)然后又回到内核空间(因为cond_wait返回后会有原子加锁的 行为),所以一来一回会有性能的问题。但是在LinuxThreads或者NPTL里面,就不会有这个问题,因为在Linux 线程中,有两个队列,分别是cond_wait队列和mutex_lock队列, cond_signal只是让线程从cond_wait队列移到mutex_lock队列,而不用返回到用户空间,不会有性能的损耗。
所以在Linux中推荐使用这种模式。
之后:
pthread_mutex_lock
xxxxxxx
pthread_mutex_unlock
pthread_cond_signal
优点:不会出现之前说的那个潜在的性能损耗,因为在signal之前就已经释放锁了
缺点:如果unlock和signal之前,有个低优先级的线程正在mutex上等待的话,那么这个低优先级的线程就会抢占高优先级的线程(cond_wait的线程),而这在上面的放中间的模式下是不会出现的。