文章目录
1、线程概述
进程是资源管理的最小单位,线程是程序执行的最小单位,也被称为轻量级进程。
1.1、线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。//线程return
2. 一个线程可以调用pthread_cancel终止同一进程中的另一个线程。//被动终止
3. 线程可以调用pthread_exit终止自己。//自己终止
方式1和方式4的区别是,方式4可以返回一个结果指针,通过pthread_join可以获取,并且会启动资源释放。
在任意一个时间点上,线程是可结合(joinable)或者是可分离的(detached)。一个可结合线程是可以被其他线程收回资源和杀死的。
在被回收之前,他的存储器资源(栈等)是不释放的。而对于detached状态的线程,其资源不能被别的线程收回和杀死,只有等到线程结束才能由系统自动释放。
默认情况,线程状态被设置为结合的。所以为了避免资源泄漏等问题,一个线程应当是被显示的join或者detach的,否则线程的状态类似于进程中的Zombie Process。会有部分资源没有被回收的。
调用函数pthread_join,当等待线程没有终止时,主线程将处于阻塞状态。如果要避免阻塞,那么
在主线程中加入代码pthread_detach(thread_id)或者在被等待线程中加入pthread_detach(thread_self())。
1.2、线程属性
typedef struct
{
int detachstate; 线程的分离状态
int schedpolicy; 线程调度策略
structsched_param schedparam; 线程的调度参数
int inheritsched; 线程的继承性
int scope; 线程的作用域
size_t guardsize; 线程栈末尾的警戒缓冲区大小
int stackaddr_set;
void* stackaddr; 线程栈的位置
size_t stacksize; 线程栈的大小
}pthread_attr_t;
1.3、线程优缺点
优:可以并发操作(对于多核或多CPU计算机来说,多线程可以最大发挥计算机能力。单核或单CPU的计算机来说,多线程其实是假的,是不停的切换调度模拟出来的)。
相对于多进程,多线程之间数据共享效率更高。
缺:线程之间的数据同步略难搞,搞不好的话就会出现莫名其妙的问题。
1.4、线程权限
可由下图概括:
线程私有 | 线程之间共享(进程所有) |
---|---|
1、局部变量 2、函数参数 3、TLS数据 |
1、全局变量 2、堆上的数据 3、函数里的静态变量 4、程序代码(任何线程都有权利读取并执行任何代码) 5、打开的文件(A线程打开的文件可以由B线程读写) |
2、线程函数
#include <pthread.h>
!!注:pthread并非Linux系统的默认库,在编译时注意加上-lpthread参数
restrict:是c99标准引入的,它只可以用于限定和约束指针,并表明指针是访问一个数据对象的唯一且初始的方式.即它告诉编译器,
所有修改该指针所指向内存中内容的操作都必须通过该指针来修改,而不能通过其它途径(其它变量或指针)来修改;这样做的好处是,
能帮助编译器进行更好的优化代码,生成更有效率的汇编代码.如 int *restrict ptr, ptr 指向的内存单元只能被 ptr 访问到,
任何同样指向这个内存单元的其他指针都是无效指针。
函数名 | 参数说明 | 返回值 | 说明 |
---|---|---|---|
int pthread_create(pthread_t *restrict tidp, const pthread_attr_t *restrict attr, void *(*start_rtn)(void), void *restrict arg); |
//返回线程的ID //线程属性,默认为NULL使用默认属性 //线程函数入口地址 //线程函数参数,传入的是参数的地址,如果有多个参数就传结构体的地址。!!注:传栈地址的时候要小心 |
成功返回0 失败返回错误编号 | |
pthread_t pthread_self(void); | 返回调用该函数的线程的线程ID | ||
void pthread_exit(void *rval_ptr); | 要返回的值 | 终止线程。!!注:如果在主线程中调用,就会终止主线程,而主线程创建的分离子线程还会跑,但是如果在主线程中return,整个进程都会结束包括所有线程 | |
int pthread_join(pthread_t thread, void **rval_ptr); | 1、线程id 2、存储被等待线程的返回值 |
成功返回0 否则返回错误编号 | 该函数阻塞等待参数thread指定的线程,被等待的线程必须是jsonable,使用此函数来释放线程资源 |
int pthread_detach(pthread_t tid); | 1、线程id | 成功返回0 否则返回错误编号 | 使线程处于分离状态,线程终止后,系统会自动释放其资源 |
int pthread_cancel(pthread_t tid); | 成功返回0 否则返回错误编号 | 请求同一进程中的其它线程退出。要注意的是,该函数并不等待线程终止,他仅仅提出请求。调用了该函数也不等于目标线程马上就会退出,目标线程有可能再运行一段时间后到达取消点才退出;甚至有可能不响应退出。!!注:具体响应操作可以通过pthread_setcancelstate、pthread_setcanceltype进行设置 | |
int pthread_equal(pthread_t tid1, pthread_t tid2); | 若相等则返回非0值,否则返回0值 | ||
int pthread_attr_init(pthread_attr_t*attr); | 0 - 成功,非0 - 失败 | 线程属性使用前必须使用该函数进行初始化 | |
int pthread_attr_destroy(pthread_attr_t*attr); | 0 - 成功,非0 - 失败 | 销毁一个目标结构,并且使它在重新初始化之前不能重新使用 | |
int pthread_attr_getdetachstate(pthread_attr_t *attr,int detachstate); | 1、线程属性变量 2、线程的分离状态属性 |
获取线程的分离属性 | |
int pthread_attr_setdetachstate(pthread_attr_t *attr,int *detachstate); | 0 - 成功,非0 - 失败 | 设置线程分离属性 | |
其他属性的获取和设置都类似 |
3、线程安全
3.1、同步的方法
3.1.1、信号量(Semaphore)
全称是多元信号量,对于允许多线程并发访问的资源,信号量是一个很好的选择。一个初始值为N的信号量允许N个线程并发访问。
线程访问资源的时候,首先获取信号量,操作如下:、
1、将信号量的值减1;
2、如果信号量的值小于0,则进入等待状态,否则继续执行。
访问完资源后,释放信号量,操作如下:
1、将信号箱的值加1;
2、如果信号量的值小于1,唤醒一个等待中的线程。
使用到的函数有:
#include<sys/sem.h>
(1)int semget(key_t key,int num_sems,int sem_flags);
key:信号量键值,可以理解为信号量的唯一性标记。
num_sems:信号量的数目,一般为1
sem_flags:有两个值,IPC_CREATE和IPC_EXCL,
IPC_CREATE表示若信号量已存在,返回该信号量标识符。
IPC_EXCL表示若信号量已存在,返回错误。
返回值:相应的信号量标识符,失败返回-1
(2)int semop(int sem_id,struct sembuf *sem_opa,size_t num_sem_ops);
sem_id:信号量标识符
sem_opa:结构如下
struct sembuf{
short sem_num;//除非使用一组信号量,否则它为0
short sem_op;//信号量在一次操作中需要改变的数据,通常是两个数,一个是-1,即P(等待)操作,
//一个是+1,即V(发送信号)操作。
short sem_flg;//通常为SEM_UNDO,使操作系统跟踪信号,
//并在进程没有释放该信号量而终止时,操作系统释放信号量
};
(3)int semctl(int sem_id,int sem_num,int command,[union semun sem_union]);
command:有两个值SETVAL,IPC_RMID,分别表示初始化和删除信号量。
sem_union:可选参数,结构如下:
union semun{
int val;
struct semid_ds *buf;
unsigned short *arry;
};
一般用到的是val,表示要传给信号量的初始值。
例子: 起两个进程进行操作
#include<stdio.h>
#include<stdlib.h>
#include<sys/sem.h>
union semun
{
int val;
struct semid_ds *buf;
unsigned short *array;
};
int sem_id;
int set_semvalue()
{
union semun sem_union;
sem_union.val = 1;
if(semctl(sem_id,0,SETVAL,sem_union)==-1)
return 0;
return 1;
}
int semaphore_p()
{
struct sembuf sem_b;
sem_b.sem_num = 0;
sem_b.sem_op = -1;
sem_b.sem_flg = SEM_UNDO;
if(semop(sem_id,&sem_b,1)==-1)
{
fprintf(stderr,"semaphore_p failed\n");
return 0;
}
return 1;
}
int semaphore_v()
{
struct sembuf sem_b;
sem_b.sem_num = 0;
sem_b.sem_op = 1;
sem_b.sem_flg = SEM_UNDO;
if(semop(sem_id,&sem_b,1)==-1)
{
fprintf(stderr,"semaphore_v failed\n");
return 0;
}
return 1;
}
void del_semvalue()
{
//删除信号量
union semun sem_union;
if(semctl(sem_id,0,IPC_RMID,sem_union)==-1)
fprintf(stderr,"Failed to delete semaphore\n");
}
int main(int argc,char *argv[])
{
char message = 'x';
//创建信号量
sem_id = semget((key_t)1234,1,0666|IPC_CREAT);
if(argc>1)
{
//初始化信号量
if(!set_semvalue())
{
fprintf(stderr,"init failed\n");
exit(EXIT_FAILURE);
}
//参数的第一个字符赋给message
message = argv[1][0];
}
int i=0;
for(i=0;i<5;i++)
{
//等待信号量
if(!semaphore_p())
exit(EXIT_FAILURE);
printf("%c",message);
fflush(stdout);
sleep(1);
//发送信号量
if(!semaphore_v())
exit(EXIT_FAILURE);
sleep(1);
}
printf("\n%d-finished\n",getpid());
if(argc>1)
{
//退出前删除信号量
del_semvalue();
}
exit(EXIT_SUCCESS);
}
3.1.2、互斥量(Mutex)
也叫互斥锁。和二元信号量类似。不同的是信号量在整个系统可以被任意线程获取并释放,也就是说,同一个信号量可以被系统中的一个线程获取之后由另一个线程释放。
而互斥量则要求谁获取谁释放,其他线程越俎代庖去释放是无效的。
使用到的函数有:
(1)int pthread_mutex_init (pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
初始化一个mutex,如果attr为NULL测按默认值初始化,另外还可以在定义互斥量的时候按照下面的方式初始化一个互斥量:
返回0表示成功
(2)int pthread_mutex_destroy (pthread_mutex_t *mutex);
销毁一个 mutex ,只有空闲状态(没有被lock或者lock之后被unlock了)的mutex才可以被销毁,销毁一个被lock的信号量,将产生(16,Device or resource busy)错误;
返回0表示成功
(3)int pthread_mutex_lock (pthread_mutex_t *mutex)
如果mutex指向的锁已经被锁定,那么当前调用锁定函数的线程将阻塞直到互斥锁被其他线程释放(阻塞线程按照优先级等待)。当pthread_mutex_lock返回时,说明互斥锁已经被当前线程成功锁定
返回0表示加锁成功
(4)int pthread_mutex_trylock (pthread_mutex_t *mutex)
该函数是pthread_mutex_lock的非阻塞版本。trylock在给一个互斥锁加锁时,如果互斥锁已经被锁定,那么函数将返回错误而不会阻塞线程
返回0表示加锁成功
(5)int pthread_mutex_timedlock(pthread_mutex_t *restrict mutex, const struct timespec *restrict abstime);
函数用于将mutex表示的互斥量锁住,如果该互斥量已经上锁,那么该函数会一直等到该互斥量解锁,等待时长为abstime指定的时间。
返回0表示加锁成功,其它表示加锁失败。
struct timespec
{
time_t tv_sec; /*second 秒*/
long tv_nsec; /*nanosecond 纳秒*/
}
struct timeval
{
time_t tv_sec; /*seconds 秒*/
suseconds tv_usec; /*microseconds 微妙*/
}
(6)int pthread_mutex_unlock(pthread_mutex_t *mutex);
函数用于将mutex表示的互斥量释放掉。
返回0表示成功
3.1.3、临界区
比互斥量更加严格的同步手段。临界区和互斥量、信号量的区别在于,互斥量、信号量在系统的任何进程里都是可见的,也就是说,一个进程创建了一个互斥量、信号量,另一个进程试图去获取该锁也是合法的。而临界区的作用范围仅限于本进程。
其实临界区就是本进程中的某一段代码,只是这段代码被加锁了。
3.1.4、读写锁
对于一段数据,多个线程同时读取总是没问题的。但是只要有一个去修改,就必须使用同步手段来避免出错。
使用信号量、互斥量、临界区来同步都可以,但是对于读取频繁,而仅仅偶尔写入的情况,会显得非常低效(每次读都要加锁解锁)。
读写锁可以避免这个问题。
读写锁有两种获取方式:共享的(Shared)和独占的(Exclusive)。
锁状态及线程以不同方式尝试获取的结果如下:
||读写锁状态 ||以共享方式获取 ||以独占方式获取 ||
|| 自由 ||成功 ||成功 ||
|| 共享 ||成功 ||等待 ||
|| 独占 ||等待 ||等待 ||
用到的函数有:
(1)int pthread_rwlock_init ( pthread_rwlock_t *restrict rwlock , const pthread_rwlockattr_t *restrict attr ) ;
(2)int pthread_rwlock_destory ( pthread_rwlock_t *rwlock )
(3)pthread_rwlock_rdlock(pthread_rwlock_t* rwlock);
在进行读操作的时候加的锁:
(4)pthread_rwlock_wrlock(pthread_rwlock_t* rwlock);
在进行写操作的时候加的锁:
(5)pthread_rwlock_unlock(pthread_rwlock_t* rwlock);
对读/写统一进行解锁:
3.1.5、条件变量
类似于一个栅栏。线程可以等待和唤醒条件变量,一个条件变量可以被多个线程等待,唤醒条件变量后,所有等待的线程都会恢复执行。
用到的函数有:使用方法类似互斥锁,貌似条件变量需要和互斥锁一起用,不然会有隐患
(1)int pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr);
(2)int pthread_cond_destroy(pthread_cond_t *cond);
(3)int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
(4)int pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abtime);
(5)int pthread_cond_signal(pthread_cond_t *cond);
(6)int pthread_cond_broadcast(pthread_cond_t *cond);
3.2、使用可重入函数
意思就是可以被多个线程同时调用的函数。可重入函数必须满足以下几点,不然就会存在隐患!!!
(1)不使用任何(局部)静态或全局的非const变量。若使用局部静态变量,局部静态变量并非私有,会受到其他线程调用影响。全局变量同理。至于const变量,本身就不会改变不用担心同步问题。
(2)不调用任何不可重入的函数。
(3)仅依赖于调用方提供的参数。
(4)不依赖任何单个资源的锁(mutex等)。
3.3、编译器过度优化造成的隐患
例1:
有如下代码:
x=0
Thread1 Thread2
lock(); lock();
x++; x++;
unlock(); unlock();
由于有lock保护,那么x的值似乎必然是2。但是,如果编译器为了提高x的访问速度,把x放到了某个寄存器里不及时写回变量,
而不同线程的寄存器各自独立,因此如果Thread1先获得了锁,然后放到了寄存器中,然后++变成了1,但是为了访问速度并没有马上写会至变量x;
此时Thread2获得了锁,x值还是0,然后++变成了1,然后写回了变量x=1;然后一段时间后Thread1将寄存器中的值1写回变量x,导致x经过两次++还是1。
例:2:
有如下代码:
x=y=0;
Thread1 Thread2
x = 1; y = 1;
r1 = y; r2 = x;
显然,r1、r2至少有一个为1,逻辑上不可能同时为0.然而事实上r1=r2=0的情况确实会发生。
原因在于几十年前,CPU就发展出了动态调度,在执行程序的时候为了提高效率有可能交换指令的顺序。
同样,编译器在进行优化的时候,也有可能为了效率交换毫不相干的两条执行的执行顺序。
所以上面的代码执行的时候可能是这样的:
x = y = 0;
Thread1 Thread2
r1 = y; y = 1;
x = 1; r2 = x;
我们可以使用关键字volatile阻止过度优化。volatile可以做到两件事情:
1、阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回。
2、阻止编译器调整volatile变量的指令顺序。但是不能阻止CPU动态调度换序。
例3:
volatile T* p = 0;
T* GetInstance()
{
if(p == NULL)
{
lock();
if(p == NULL)
{
p = new T;
}
unlock();
}
return p;
}
乍一看,当函数返回时,p总是指向一个有效的对象。但是由于CPU的乱序执行,会有问题。
p = new T;包含了三个步骤:
(1)分配内存
(2)在内存的位置上调用构造函数
(3)将内存的地址赋值给p
然而,(2)(3)的顺序可以颠倒,也就是说p的值不是NULL了,已经分配好内存了,但是还没有构造完毕,
如果此时另一个线程对GetInstance并发调用,那么if判断p不是NULL,就返回了尚未构造完毕的对象的地址,此时使用的话会有隐患。
要阻止CPU换序可以调用CPU提供的一条执行——barrier,它可以阻止CPU将barrier前的指令交换到barrier之后。
许多体系结构的CPU都提供barrier指令,不过他们的名称各不相同,例如POWERPC提供的其中一条名叫lwsync。我们可以这样使用:
#define barrier() __asm__ volatile ("lwsync")
volatile T* p = 0;
T* GetInstance()
{
if(p == NULL)
{
lock();
if(p == NULL)
{
T* tmp = new T;
barrier();
p = tmp;
}
unlock();
}
return p;
}
4、线程模型
线程实现机制:一开始的是LinuxThreads,
后来有了IBM开发的NGPT(Next Generation POSIX Threads)
再后来是NPTL(Native POSIX Threads Library)
LinuxThreads和NPTL都是采用一对一的线程模型,NGPT采用的是多对多的线程模型。
现在用的基本都是NPTL。
1、一对一模型:一个用户线程对应一个内核线程(内核调度实体)。
优:此种模型可以实现真正的并发。
缺:许多操作系统限制了内核线程的数量,导致用户线程数量受到限制;
而且内核线程调度时,上下文切换开销较大,导致用户线程的执行效率下降。
2、多对一模型:多个用户线程对应一个内核线程(内核调度实体)。
优:相对于一对一模型,线程切换要快速得多;
并且用户线程数量几乎没有限制。
缺:若一个用户线程阻塞,那么内核线程也阻塞了,导致其他用户线程无法执行;
在多处理其系统上,处理器增多对这种模型的线程性能也不会有明显的帮助。
3、多对多模型:多个用户线程对多个内核线程(内核调度实体)。
集上两个模型z的优点,去上两个模型的缺点。