线程概念
什么是线程:
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是"一个进程内部的控制序列"。
- 一个进程至少有一个线程。一个进程中可以并发多个线程,每条线程并行执行不同的任务。
- 在Unix System V及SunOS中也被称为轻量级进程(lightweight process),但轻量级进程更多指的是内核线程(kernel thread),而把用户线程(user thread)称为线程。
- 线程在进程内部运行,本质是在进程地址空间内运行。
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。Linux下,线程是以进程的PCB模拟实现的,所以Linux下PCB是线程。进程则是线程组。
- 透过虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
- Linux下,线程是CPU调度的基本单位,进程是资源分配的基本单位。
线程的优点:
- 线程的创建和销毁成本比进程低。
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少的多。
- 线程占用的资源要比进程少很多。
- 线程之间共用同一个虚拟地址空间,线程之间的通信更加方便。
- 能充分利用多处理器的可并行数量。
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
- 计算密集型应用, 为了能在多处理器系统上运行,将计算分解到多个线程中实现。
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点:
- 性能损失:一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步与调度开销,而可用的资源不变。
- 健壮性低:编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制:进程是访问控制的基本粒度,在一个线程中调用某些系统调用会对整个进程(所有线程)造成影响。
线程异常:
- 单个线程如果出现除0,野指针问题导致线程崩溃,进程也会随着崩溃。
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随机退出。
进程和线程:
- 进程是资源分配的基本单位;线程是CPU调度的基本单位。
- 线程共享进程数据,也拥有自己的一部分数据:
- 线程ID。
- 一组寄存器。
- 栈。
- errno。
- 信号屏蔽字。
- 调度优先级。
- 进程的多个线程共享同一地址空间,因此代码段、数据段都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表。
- 每种信号的处理方式(SIG_IGN、SIG_DFL或自定义信号处理函数)。
- 当前工作目录。
- 用户ID和组ID。
多进程和多线程的区别:
- 多进程和多线程都可以并发/并行完成任务。
- 一般能用多进程完成的任务都可以使用多线程完成。
- 多进程主要应用在对主进程安全要求较高的程序,如:服务器、bash,这两个如果使用多线程,一个线程出错就会导致程序崩溃,而使用多进程,一个进程出错不会影响到其他进程,尤其是主进程。
线程控制
操作系统并没有提供系统调用接口,大佬们封装了一套线程库,供弱鸡们使用;由于没有使用系统调用接口,因此有人称创建的线程是用户态线程,在内核中对应有一个轻量级进程调度程序运行。
POSIX线程库:
- 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以"pthread_"开头。
- 要使用这些函数库,需要包含头文件<pthread.h>。
- 编译时需要链接库,"-lpthread"或"-pthread",建议使用前者。
线程创建
接口介绍:
功能:创建一个新的线程。
原型:int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
参数:
thread:输出型参数,返回新线程ID。
attr:线程属性,通常置NULL, 使用默认属性。
start_routine:线程的入口函数。
arg:传递给线程入口函数的实参。
返回值:成功返回0,失败返回错误码(errno > 0)。
注意:
- 传统的函数是成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误,pthreads函数出错时不会设置全局变量errno(大部分POSIX函数会这样做)。而是将错误代码通过返回值返回。
- pthreads同样也提供了线程内的errno变量,以支持其他使用errno的代码,对于pthreads函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小。
代码演示:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
// 线程入口函数
void* thr_start(void* arg){
// 死循环
while(1){
printf("new thread is running...\n");
sleep(1);
}
}
int main(){
pthread_t tid;
// 线程创建
int ret = pthread_create(&tid, NULL, thr_start, NULL);
if(ret != 0){
// 线程创建失败
printf("pthread create error!\n");
return -1;
}
// 死循环
while(1){
printf("main thread is running...\n");
sleep(1);
}
return 0;
}
编译运行程序,效果如下:
进程ID和线程ID:
- 在Linux中,目前的线程实现是Native POSIX Thread Library,简称NPTL。在这种实现下,线程又被称为轻量级进程(Light Weighted Process),每一个用户态的线程,在内核中都对应一个调度实体,也拥有自己的进程描述符(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 | pid_t gettid(void) | pid_t pid |
进程ID | pid_t getpid(void) | pid_t tgid |
现在介绍的线程ID,不同于pthread_t类型的线程ID,和进程ID一样,线程ID是pid_t类型的变量,而且是用来唯一标识线程的一个整型变量。
线程ID的查看:
运行前面的线程创建程序,我们来查看一下它们的线程ID和线程组ID(进程ID)。
从上述可以看到线程组ID(进程ID)(tgid)为1242;两个线程的ID(pid)分别为1242、1243。
ps命令中的-L选项,会显示如下信息:
- LWP:线程ID,即gettid()系统调用的返回值。
- NLWP:线程组内线程的个数。
- 从上面可以看出,thread_creat进程的ID是1242,下面有一个线程的ID也是1242,这不是巧合。线程组内的第一个线程,在用户态被称为主线程(main thread),在内核中被称为group leader,内核在创建第一个线程时,会将线程组的ID的值设置成第一个线程的线程ID,group_leader指针则指向自身,即主线程的进程描述符。所以线程组存在一个线程ID等于进程ID,而该线程即为线程组的主线程。至于线程组其他线程的ID则由内核负责分配,其线程组ID总是和主线程的线程组ID一致,无论是主线程直接创建线程,还是创建出来的线程再次创建线程,都是这样。
注意:线程和进程不一样,进程有父进程的概念,但在线程组里面,所有的线程都是对等关系。
线程ID及线程地址空间分配:
- pthread_create()函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
- 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度的最小单元,所以需要一个数值来唯一表示该线程。
- pthread_create()函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后序操作,就是根据该线程ID来操作的。
- 线程库NPTL提供了pthread_self()函数,可以获得线程自身的ID;
pthread_t pthread_self(void);
- pthread_t到底是什么类型呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个线程地址空间上的一个地址。
我们将代码略作修改,打印一下两个线程的线程ID(线程地址空间入口):
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
// 线程入口函数
void* thr_start(void* arg){
// 死循环
while(1){
printf("new thread:%p is running...\n", pthread_self());
sleep(5);
}
}
int main(){
pthread_t tid;
// 打印一下栈的打开地址
int i = 0;
printf("stack: %p\n", &i);
// 线程创建
int ret = pthread_create(&tid, NULL, thr_start, NULL);
if(ret != 0){
// 线程创建失败
printf("pthread create error!\n");
return -1;
}
// 死循环
while(1){
printf("main thread:%p is running...\n", pthread_self());
sleep(5);
}
return 0;
}
编译运行程序,效果如下:
从上述运行结果可以看出,线程地址空间都在进程地址空间的栈上。
线程终止
进程的终止方法不能用到线程中的,因为进程的终止方法会释放进程地址空间,如果一个线程释放了进程地址空间,会导致该线程组的其他线程出错。所以,我们需要终止单个线程而不终止进程的方法,共有三种:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用pthread_exit终止自己。
- 一个线程可以调用pthread_cancel终止同一个进程中的另一个线程。
线程可以调用pthread_exit()终止自己:
接口介绍:
功能:线程终止
void pthread_exit(void *retval);
参数:
retval:保存线程返回值。
注意:pthread_exit或者return返回的指针所指向的内存单元必须是全局或者是堆上内存,
不能在线程函数的栈上分配。
代码演示:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
// 线程入口函数
void* thr_start(void* arg){
int cnt = 1;
// 循环五次
while(cnt <= 6){
printf("new thread is running...\n");
sleep(1);
++cnt;
}
// 线程退出
pthread_exit(NULL);
}
int main(){
pthread_t tid;
// 线程创建
int ret = pthread_create(&tid, NULL, thr_start, NULL);
if(ret != 0){
// 线程创建失败
printf("pthread create error!\n");
return -1;
}
// 死循环
while(1){
printf("main thread is running...\n");
sleep(1);
}
return 0;
}
我们先另开一个终端,循环查看线程,命令如下:
[sss@aliyun ~]$ while :; do echo "========================================================================"; ps -efL | grep thread_exit | grep -v grep; sleep 2; done;
然后,回到前面终端,编译运行程序,两终端效果如下:
一个线程调用pthread_cancel()终止同一进程中的另一个线程:
接口介绍:
功能:取消一个执行中的线程
int pthread_cancel(pthread_t thread);
参数:
thread:线程ID。
返回值:成功返回0,失败返回错误码。
代码演示:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
// 线程入口函数
void* thr_start(void* arg){
// 死循环
while(1){
printf("new thread is running...\n");
sleep(1);
}
}
int main(){
pthread_t tid;
// 线程创建
int ret = pthread_create(&tid, NULL, thr_start, NULL);
if(ret != 0){
// 线程创建失败
printf("pthread create error!\n");
return -1;
}
int cnt = 1;
// 死循环
while(1){
printf("main thread is running...\n");
sleep(1);
if(cnt == 6){
// 取消指定线程
pthread_cancel(tid);
}
++cnt;
}
return 0;
}
同样的,先打开一个终端,输入下面命令,循环查看线程:
[sss@aliyun ~]$ while :; do echo "========================================================================"; ps -efL | grep thread_cancel | grep -v grep; sleep 2; done;
编译运行程序,两个终端效果如下:
前面进程的学习,我们知道有僵尸进程,那么有僵尸线程吗,我们来测试一下:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
// 线程入口函数
void* thr_start(void* arg){
// 执行两次
for(int i = 0; i < 2; ++i){
printf("new thread is running...\n");
sleep(1);
}
pthread_exit(0);
}
int main(){
pthread_t tid;
// 线程创建
int ret = pthread_create(&tid, NULL, thr_start, NULL);
if(ret != 0){
// 线程创建失败
printf("pthread create error!\n");
return -1;
}
// 执行五次
for(int i = 0; i < 5; ++i){
printf("main thread is running...\n");
sleep(1);
}
pthread_exit(0);
}
我们另开一个终端,输入下面命令,循环查看线程状态:
[sss@aliyun ~]$ while :; do echo "==================================================================================================="; ps -eflL | grep test_thread | grep -v grep; sleep 1; done;
编译运行程序,两终端效果如下:
我们看到,非主线程线程先退出,并不会产生僵尸线程。
我们将代码修改一下,让主线程先退出,再来看一下效果:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
// 线程入口函数
void* thr_start(void* arg){
// 执行五次
for(int i = 0; i < 5; ++i){
printf("new thread is running...\n");
sleep(1);
}
pthread_exit(0);
}
int main(){
pthread_t tid;
// 线程创建
int ret = pthread_create(&tid, NULL, thr_start, NULL);
if(ret != 0){
// 线程创建失败
printf("pthread create error!\n");
return -1;
}
// 执行两次
for(int i = 0; i < 2; ++i){
printf("main thread is running...\n");
sleep(1);
}
pthread_exit(0);
}
编译运行,效果如下:
可以看到主线程成为了僵尸线程。
总结:线程退出也会成为僵尸线程(普通线程体现不出来,主线程能体现出来),成为僵尸线程会导致线程地址空间无法回收利用,造成内存泄漏。
线程等待
为什么需要线程等待?
- 已经退出的线程,其空间没有释放,仍然在进程的地址空间内。
- 创建新的线程不会复用刚才退出线程的地址空间。
接口介绍:
功能:获取指定线程的返回值,允许系统回收资源。
int pthread_join(pthread_t thread, void** value_ptr);
参数:
thread:线程ID。
value_ptr:它指向一个指针,后者指向线程的返回值。
返回值:成功返回0,失败返回错误码(errno > 0)。
调用该函数的线程将挂起等待,直到ID为thread的线程终止。thread线程以不同的方法终止,通过pthread_join()得到的终止状态是不同的,总结如下:
- 如果thread线程通过return返回,value_ptr所指向的单元里存放的是thread线程函数的返回值。
- 如果thread线程被别的线程调用pthread_cancel()异常终止,value_ptr所指向的单元存放的是常数PTHREAD_CANCELED(-1)。
- 如果thread线程是自己调用pthread_exit()终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
- 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ptr参数。
代码演示:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
// 线程1
void* thr1_start(void* arg){
printf("thread1 is running...\n");
return (void*)"return";
}
// 线程2
void* thr2_start(void* arg){
printf("thread2 is running...\n");
pthread_exit((void*)"pthread_exit");
}
// 线程3
void* thr3_start(void* arg){
// 死循环
while(1){
printf("thread3 is running...\n");
sleep(1);
}
}
int main(){
pthread_t tid1, tid2, tid3;
// 创建线程
int ret = pthread_create(&tid1, NULL, thr1_start, NULL);
if(ret != 0){
// 线程创建失败
printf("thread create error!\n");
return -1;
}
// 创建线程
ret = pthread_create(&tid2, NULL, thr2_start, NULL);
if(ret != 0){
// 线程创建失败
printf("thread create error!\n");
}
// 创建线程
ret = pthread_create(&tid3, NULL, thr3_start, NULL);
if(ret != 0){
// 线程创建失败
printf("thread create error!\n");
}
// 接收退出返回值
void* recv = NULL;
// 等待进程tid1
ret = pthread_join(tid1, &recv);
printf("thread1 join return: %d\trecv: %s\n", ret, (char*)recv);
// 等待进程tid2
ret = pthread_join(tid2, &recv);
printf("thread2 join return: %d\trecv: %s\n", ret, (char*)recv);
// 终止线程tid3
pthread_cancel(tid3);
// 等待进程tid3
ret = pthread_join(tid3, &recv);
printf("thread3 join return: %d\trecv: %d\n", ret, recv);
return 0;
}
编译运行,效果如下:
线程分离
- 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成资源泄露。
如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
线程分离就是将线程的joinable属性设置为detach属性,detach属性的线程,退出后资源直接自动被回收,这类线程不能被等待。
接口介绍:
功能:线程分离
int pthread_detach(pthread_t thread);
参数:
thread:要分离线程的tid。
返回值:成功返回0,失败返回错误码(errno > 0)。
注意:
- 线程的分离对于一个线程来说,任意线程在任意位置调用都可以。一般在线程创建之后直接分离线程或者线程入口函数处分离自己。
- 如果在主线程中等待,在线程中分离自己,主线程需要延时,不然主线程会在线程分离自己之前就等待完毕。其实这是非法操作,一般我们将线程分离之后,就不需要去等待了。
代码演示:
#include <stdio.h>
#include <pthread.h>
#include <errno.h>
// 线程入口函数
void* thr_start(void* arg){
printf("thread is running...\n");
pthread_exit(0);
}
int main(){
pthread_t tid;
// 线程创建
int ret = pthread_create(&tid, NULL, thr_start, NULL);
if(ret != 0){
// 线程创建失败
printf("thread create error!\n");
return -1;
}
// 线程分离
pthread_detach(tid);
// 线程等待
ret = pthread_join(tid, NULL);
if(ret == EINVAL){
// 线程不是joinable属性
printf("thread detach successful!\n");
}
pthread_exit(0);
}
编译运行程序,效果如下: