一、线程概念
1、首先来介绍一下线程的基础概念
(1)在程序里的一个执行路线就是一个线程,更准确的是:“一个进程内部的控制序列”
(2)一个进程至少有一个线程
2、线程和进程的区别
谈到线程就必须联系到进程,因为线程就是进程的一个执行流,下来我们深入解析一下:
(1)进程是分配资源的最小单位
(2)线程是程序执行或者说调度的最小单位
(3)我们常说线程是在进程内部执行的,更准确的说是在进程的地址空间内执行的,线程共享进程的资源,但是也拥有自己的一些独有的资源包括:线程id,一组寄存器,栈,errno,信号屏蔽字,调度优先级
一进程的多个线程共享
(1)同一个地址空间,因此Text Segment,DataSegment都是共享的,如果定义一个函数在各线程中都是可以共享,如果定义一个函数,在各线程都是可以调用,如果定义一个全局变量 ,在各线程都可以访问到,各线程还可以共享以下资源:
【1】文件描述表
【2】每种信号的处理方式
【3】当前工作目录
【4】用户id和组id
所以说线程是在进程内部执行的
3、线程的优点
(1) 创建一个新线程比创建一个新进程代价要小的多,只需要创建一个pcb就可以了(线程叫做tcb)
(2)与进程切换相比线程切换的需要操作系统做的工作要少的多,因为共享一份资源所以资源就不需要切换,只要把线程之间不同的部分进行切换
(3)线程占有的资源比进程要少的多
(4)能充分利用多处理器的可并行数量
(5)在等待慢速io结束的同时,程序可执行其他的计算任务
(6)计算密集型应用,为了能在多处理器系统上运行,将计算分散到多个线程来执行
(7)I/O密集型应用,为了提高性能,将I/O操作重叠,线程可以同时等待不同的I/O操作
4、线程的缺点
(1)性能损失,
一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享一个处理器,如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的损失指的是增加了额外的同步和调度开销,而可用的资源不变
(2)健壮性降低
编写多线程需要更全面深入的考虑,在一个多线程程序中,因时间上的偏差或者共享了不应该共享的变量而造成不良影响的可能性很大,换句话说线程之间是缺乏保护的
(3)缺乏访问控制
编写和调试一个多线程程序比单线程程序要困难得多
二、线程控制
了解线程控制就必须了解一下POSIX库,这个库里有着控制线程的很多函数
1、POSIX库
(1)与线程相关的函数构成了一个完整的序列,绝大多数函数的名字都是以pthread_开头的
(2)要使用这些函数要通过引入头文件“pthread.h”
(3)链接这些线程函数库时要使用编译命令的“-lpthread”选项
下来介绍这些函数:
(1)pthread_create函数
函数原型:
函数功能:
创建一个新线程
函数参数解释:
thread:返回线程id
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,表示线程启动后要执行的函数
arg:传给线程启动函数的参数
返回值:
成功返回0,失败返回错误码
补充说明:
错误检查:传统的一些函数都是成功返回0,失败返回-1,并对全局变量errno赋值以指示错误;pthreads函数不会设置全局变量errno,而是将错误码通过返回值返回;phreads同样也提供了线程内的errno变量,以支持它使用errno的代码。对于pthreads函数的错误,建议通过返回值判定,因为读取返回值比读取线程中的errno开销要小
下面用一个小程序来使用这个函数创建一个线程
运行结果如下图:
在这里一共有两个线程,一个是主函数,一个是使用pthread_create函数创建出来的线程,主函数运行起来后的线程叫做主线程,而创建出来的线程叫做pthread1,如果只有一个主函数的话就是单进程单线程模式,加上pthread_create函数之后创建出了一个新线程就是单进程双线程模式
三、进程ID和线程ID
在学习进程的时候我们知道每一个进程都有一个唯一表示该进程的编号,叫做进程ID用来区分不同进程,在线程中也有类似的概念
在Linux中目前的线程实现是NPTL。在这种实现下,线程又被成为轻量级进程,每一个用户态的线程,在内核中都对应着一个调度实体,也拥有自己的进程描述符(task_struct结构体)
没有线程之前,一个进程对应着内核中的一个进程描述符,对应一个进程ID,但是引入线程概念之后,情况发生了变化,一个用户进程下管辖N个用户态线程,每个线程作为一个独立的调度实体在内核态都有自己的进程描述符,进程和内核的描述符一下子就变成了1:N的关系,POSIX库又要求进程内的所有线程调用getpid函数是返回相同的进程ID。为了解决这个问题,Linux内核引入了线程组的概念,
进程描述符的结构体;
struct task_struct {
…
pid_t pid;
pid_t tgid;
…
struct task_struct *group_leader;
…
struct list_head thread_group;
…
};
多线程的进程又被称为线程组,线程组内的每一个线程在内核之中都存在一个进程描述符(task_struct)与之对应,进程描述符中的pid,表面上对应的是进程ID实际上对应的是线程ID,进程描述符中的tgid含义是Thread Group ID,该值对应的是用户层面的进程ID
线程id 调用gettid函数来获取 对应数据结构中的 pid_t pid
进程id 调用getpid函数来获取 对应数据结构中的 pid_t tgid
现在介绍的线程ID,不同于pthread_t类型的线程ID,和进程ID一样,线程ID是pid_t类型的变量,而且是用来唯一标识线程的一个整型变量
查看线程ID可以使用命令:
如图可见,pthread是多线程的进程ID是2758线程ID分别是2758,2759
Linux提供了gettid函数用来返回其线程ID,但是glibc却没有将该系统调用封装起来,将开放接口来供程序员使用。如果确实需要获得线程ID,可以采用如下方法:
从上面可以看出有一个线程的线程ID和进程ID相同,线程组内第一个线程,在用户态被称为主线程,在内核中被称为group leader,内核在创建第一个线程时,会将线程组的ID的值设置为第一个线程的线程ID,group_leader指针则指向自身,即主线程的进程描述符,所有那个和进程ID相同的线程就是线程组的主线程
至于线程组的其他线程的ID则由内核负责分配,其线程组ID总是与主线程的线程组ID一致,无论是主线程直接创建线程,还是创建出来的线程再创建线程都是一样。
补充说明:线程和进程不一样,进程中有父进程的概念,而在线程组中所有的线程都是对等关系。
四、 线程ID与进程地址空间布局
pthread_create函数会产生一个线程ID,存放在第一个参数指向的地址中,该线程ID与前面所说的线程ID不同
前面讲的线程ID属于进程调度范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程
pthread_create函数,产生并标记在第一个参数指向的地址中的线程ID中属于NPTL线程库的范畴。线程库的操作,就是根据该线程ID来操作线程的。
线程库NPTL提供了pthread_self函数,来获得线程自身的ID
pthread_t类型的线程ID,本质上就是一个进程地址空间上的一个地址
地址空间图示:
五、线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
1、从线程函数return。这种方法对主线程从main函数return相当于调用exit函数
2、线程可以调用pthread_exit函数终止自己
3、一个线程可以调用pthread_cancel终止同一进程中的另一个线程
先来认识这几个函数:
【1】pthread_exit函数
函数原型:
函数功能:
线程终止
参数解释:
参数:不要指向一个局部变量
返回值:
无返回值,和进程一样,线程结束后无法返回自己的调用者
需要注意得是,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者用malloc分配的,不能在线程函数的栈上分配,因为当其他线程得到这个返回指针时,线程函数已经退出了
【2】pthread_cancel函数
函数功能:
取消一个执行中的线程
函数原型:
参数解释:线程ID
返回值:
成功返回0,失败返回错误码
六、线程等待与分离
1、首先来了解一下为什么要线程等待呢?
【1】已经退出的线程,其空间没有被释放,仍然在进程的地址空间里面
【2】创建新的线程不会复用刚才退出线程的地址空间
来了解一下线程等待和分离要用到的函数:
函数原型:
函数功能:
等待线程结束
参数解释:
参数一:线程ID
参数二:它指向一个指针,这个指针指向线程的返回值
返回值:
成功返回0,失败返回错误码
说明:
调用该函数的线程将被挂起等待,之道id为thread的线程终止,thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的。总结如下:
【1】如果thread线程是通过return返回,vlaue_ptr指向的单元里存放的是thread线程函数的返回值
【2】如果thread线程被其他线程使用pthread_cancel函数终止掉,value_ptr所指向的单元里存放得是常数PTHREAD_CANCELED
【3】如果thread线程是自己调用pthread_exit函数终止掉的value_ptr所指向的单元存放得是传给pthread_exit函数的参数
用一段代码来实现一下这个功能:
运行结果如下:
对代码进行解释:
在代码中包括主线程一共有三个线程,线程1创建之后通过return终止,线程2创建之后通过调用pthread_exit函数终止 ,线程3创建之后三秒之后通过调用pthread_canceled函数终止掉,三个线程至此都终止掉了
分离线程:
【1】默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,造成系统泄露
【2】如果不关心线程的返回值,join是一种负担,这个时候我们可以告诉系统,当线程退出时自动释放线程资源
先来介绍线程分离所需要的函数:
函数原型:
函数功能;
分离线程
函数参数:
线程ID
返回值:
成功返回0,失败返回错误码
可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离。
joinable和分离是冲突的,一个线程不能既是joinable的又是分离的
线程分离测试代码:
运行结果:
对代码进行解释:
在主函数中创建了一个线程,线程的执行函数中将线程自己分离掉,这时再对线程执行pthread_join函数,这个函数会把线程变成joinable的,而一个线程不能既是joinable的又是分离的,所以这里就会失败。这个程序就是用来证明一个线程不能既是joinable的又是分离的。
七、线程同步与互斥
先来介绍一个概念:
互斥量(mutex)
1、大部分情况下,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况下,变量归属单个线程,其他线程无法获得这个变量
2、但很多情况下,有些变量需要在多个线程之间共享,这样的变量成为共享变量,可以通过数据的共享来实现线程之间的交互
3、多个线程并发的操作共享变量,会带来一些问题
来看一个案例:
运行结果:
这段代码是一个售票系统,四个线程代表四个买票的人,一共有一百张票,票数是一个全局变量对于四个线程都是可见的,每个线程都执行一个动作对票数进行–操作,每次进去先判断有没有票,有的话就买否则就退出,从逻辑上看确实没有错,可是结果却不正确,因为又出现了两个人买了同一张票,又出现了票的负数,这显然是不对的。
可是为什么会出现这个结果呢,每次进去都会判断票数是不是>0,可是还是会出现负数?
【1】if语句判断为真之后,代码可以并发的切换到其他线程
【2】usleep可以模拟漫长业务的过程,在这个漫长的业务过程中,可能有多个线程会进入该代码段
【3】–ticket本身就不是一个原子操作,中间很可以会切换到另一个线程,这是别的线程已经可能将最后一张票买走了,等到这个线程切回来的时候,只会继续执行刚才剩下的操作,不会再判断了
提取–操作的汇编代码:
可以看到–操作实际上做了三步:
【1】将共享变量ticket加载到寄存器上,
【2】然后对ticket执行–操作,对变量进行更新
【3】将新的变量值写回原来的内存地址
要解决上面的问题必须做到三点:
【1】代码必须要有互斥行为,当代码进入临界区执行时,不允许其他线程进入该临界区
【2】如果多个线程同时要求执行临界区里面的代码,并且临界区没有线程执行,那么只允许一个线程进入该临界区
【3】如果线程不在临界区内执行,那么该线程不能阻止其他线程进入临界区
要做到这一点本质上就是需要一把锁。Linux提供的这把锁叫做互斥量
互斥量将代码分为三部分:如下图
与互斥量相关的接口:
【1】初始化互斥量
方法一:静态分配
static pthread_mutex_t foo_mutex = PTHREAD_MUTEX_INITIALIZER;
方法二:动态分配
pthread_mutex_init函数
函数原型:
参数解释:
参数一:要初始化的互斥量
参数二:NULL
返回值:
成功返回0失败返回错误码
【2】销毁互斥量
销毁互斥量时要注意:
1、使用PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁
2、不要销毁一个已经加锁的互斥量
3、已经销毁的互斥量要确保后面不会再尝试加锁
函数参数:要销毁的互斥量
返回值:
成功返回0,失败返回错误码
互斥量加锁和解锁
函数介绍:
函数原型:
参数解释:
要加锁或者解锁的互斥量
返回值:
成功返回0,失败返回错误码
调用pthread——lock时,可能会遇到以下几种情况
1、互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
2、发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_lock调用会陷入阻塞,等待互斥量解锁
对上面的售票系统进行改善:
运行结果:
为什么pthread_cond_wait函数需要互斥量?
【1】条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去条件也不会满足,所以必须有一个线程通过某些操作,改变共享变量,使原来不满足的条件得到满足,并且友好的通知等待在条件变量上的线程。
【2】条件不会无缘无故的满足了,必然会牵扯到共享数据的改变,所以一定要用互斥锁来保护,没有互斥锁就无法安全的获取和修改共享数据
按照上面的说法,我们设计出如下的代码,:先上锁,发现条件不满足,解锁,然后等待在条件变量上
pthread_mutex_lock(&mutex);
while (condition_is_false) {
pthread_mutex_unlock(&mutex);
//解锁之后,等待之前,条件可能已经满⾜,信号已经发出,但是该信号可能被错过
pthread_cond_wait(&cond);
pthread_mutex_lock(&mutex);
}
pthread_mutex_unlock(&mutex);
【1】 由于解锁和等待不是原子操作,调用解锁之后,pthread_cond_wait之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么pthread_cond_wait将错过这个信号,可能会导致线程永远阻塞在这个pthread_cond_wait。所以解锁和等待必须是一个原子操作。
【2】进入pthread_cond_wait函数中会去看条件变量是不是等于0;等于就将互斥量变成1,直到cond_wait返回,把条件变量改为1,把互斥量恢复成原样。
补充:条件变量使用规范:
等待条件代码:
pthread_mutex_lock(&mutex);
while(条件为假)
pthread_cond_wait(cond,mutex);
修改条件
pthread_mutex_unlock(&mutex);
给条件发送信号代码:
pthread_mutex_lock(&lock);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);
八、生产者消费模型
在前面我们已经介绍过生产者消费者模型,这里利用线程同步与互斥再来实现一下:
代码如下:
运行结果:
九、POSIX信号量
POSIX信号量和SystemV信号量相同,都是用于同步操作,达到无冲突的访问公共资源的目的。但POSIX信号量可以用于线程同步
先来介绍相关的函数:
初始化信号量函数:
sem_init函数:
函数原型:
参数解释:
参数一:指向信号量
参数二:0表示线程间共享,非零表示进程间共享
参数三:信号量初始值
返回值:成功返回0,失败返回-1,并将errno置成错误码
销毁信号量函数:sem_destroy
参数解释:
指向信号量
返回值:成功返回0,失败返回-1,并将errno置成错误码
等待信号量函数:
sem_wait
函数原型:
参数解释:
指向信号量
返回值:成功返回0,失败返回-1,并将errno置成错误码
发布信号量函数:
sem_post
函数原型:
参数解释:
指向信号量
返回值:成功返回0,失败返回-1,并将errno置成错误码
学习完上面这些后,我们可以将前面的基于链表的生产者消费者模型利用固定大小的循环队列来实现:
代码如下:
运行结果:
十、读写锁
与读写锁相关的函数
在编写多线程时有一种情况是十分常见的,那就是那些公共数据修改的次数比较少,相比较改写,读的次数很多,通常而言,在读的过程中往往伴随着查找的操作,中间耗时很长,给这种代码段加锁,会极大的降低我们程序的效率所以就出现了这种方法来专门处理多读少写的情况。这种方法就是读写锁,读写锁是一种自旋锁
注意:写独占,读共享,写锁优先级高
先来认识与读写锁相关的函数
初始化锁函数和销毁锁函数:
函数原型:
参数解释:
参数一:rwlock:指向读写锁
attr:NULL
返回值:
成功返回0,失败返回错误码
加锁和解锁函数:
函数原型:
参数解释:
指向读写锁
返回值:
代码如下:
运行结果: