文章目录
一. 什么是线程?
1. 线程概念
线程(thread),是进程中的一条执行流,是被系统独立调度和分派的基本单位。一个标准的线程由线程ID、当前指令指针、寄存器集合和堆栈组成,此外一个线程可与同属一个进程组的其它线程共享进程所拥有的全部资源,同一进程中的多个线程之间可以并发执行。
线程是程序中一个单一顺序执行流,在单个程序中同时运行多个线程完成不同的工作,称为多线程。
2. 重新理解进程
一开始学习进程时,老师告诉我们进程的概念是:
- 一个运行起来的程序叫做进程。
- 每个进程系统都会为其分配一个:task_struct(PCB)、mm_struct(进程地址空间)和页表这三个数据结构来描述和管理进程。
下图是站在用户的角度(俯视)去理解进程的。
在一个进程里的一个执行流就叫做线程,每一个进程至少都有一个主执行流,即 main 函数。这一个个执行流的特征包括:
- 每个执行流拥有自己专属的 task_struct
- 所有执行流共用同一个进程地址空间和页表
- 透过虚拟进程地址空间,可以看到进程的大部分资源,操作系统将进程资源合理分配给每个执行流,就形成了线程执行流
站在线程的角度上,我们之前理解的进程是:只具有一个执行流(线程)的进程。
站在系统的角度(仰视)上:进程是承担系统资源分配的基本实体。通常在一个进程中可以包含若干个线程,这些线程可以利用进程所拥有的资源。在引入线程的操作系统中,通常是把进程作为分配系统资源的基本单位,而把线程作为独立运行和独立调度的基本单位。由于线程比进程小,故它的调度所付出的开销就会小得多,能更高效提高系统内多个程序间并发执行的效率,从而显著提高系统资源的利用率和吞吐率。
PS:线程也称为轻量级进程。像进程一样,线程在程序中有独立、并发的执行路径,每个线程都有它自己私有的栈空间、自己的程序计数器和自己的寄存器。但是他们共享全局数据区、文件描述符。
3. 线程优缺点
优点:
- 创建一个新线程的代价要比创建一个新进程的代价小得多
- 切换两个线程的时间比切换两个进程的时间少的多
- 线程间通信比进程间通信容易,同一线程共享的有堆、全局变量、静态变量、打开文件描述符表等,而独自占有栈
缺点:
- 高并发场景下存在线程安全问题,编写代码时还需要考虑线程之间的同步与互斥
- 调试起来麻烦,因为线程之间存在一定的耦合度
- 一个线程出问题,可能导致整个进程都崩溃
4. 线程周期
线程生命周期由新建、就绪、运行、阻塞、死亡五部分组成。
5. 线程调度
当有线程进入了就绪状态,需要由线程调度程序来决定何时执行,根据优先级来调度。
6. 线程工作原理
一个进程中的多个线程共享同一个进程地址空间,除了栈以外共用其他所有的数据空间。这就意味着它们可以访问相同的变量和对象。尽管这让线程之间共享信息变得更容易,但必须小心,确保它们不会妨碍同一进程里的其他线程。
7. 线程异常
多线程没有内存隔离,其中一个线程发生异常(比如除零,野指针等)导致线程异常崩溃,操作系统接收到异常信号后为了绝对安全的考虑会把整个进程的数据结构全销毁,包括:
- 所有线程的 task_struct
- 共用的 mm_struct
- 共用的页表
一个线程异常会导致整个进程崩溃,所以多线程程序调试起来较为麻烦。
8. 线程资源
main 函数和信号处理函数是同一个进程地址空间中的多个控制流程,多线程也是如此,但是比信号处理函数更灵活。信号处理函数的控制流程只是在信号递达时产生,在处理完信号之后就结束,而多线程的控制流程可以长期存在,操作系统会在个线程之间调度和切换,就像在多个进程之间的调度和切换一样。由于同一进程的多个线程共享同一地址空间,因此数据段、代码段都是共享的。如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到。下面总结一下所有线程共享的资源:
- 进程地址空间
- 打开文件描述符表。
- 信号的处理方式(SIG_IGN、SIG_DFL 或者自定义信号处理函数)
- 当前工作目录
- 用户ID和组ID
下面是每个线程独有的资源:
- 线程ID
- 私有栈空间
- errno变量
- 调度优先级
- 信号屏蔽字(blocked表)
- 上下文、程序计数器和一组寄存器。
二. 为什么要有线程?
线程在地址空间中运行。以前服务器端提供服务多采用多进程机制,而多进程机制需要 fork 子进程,子进程 fork 后需要单独的地址空间和其他系统资源,系统资源的开销较大。并且进程之间共享数据需要用进程间通信机制,这也增加了编程难度。
现在较多的服务端程序采用多线程机制提供服务,这种机制消耗资源少,也便于线程间共享数据,线程也有单独的栈空间,但消耗的时间、空间成本比进程少许多。在一个进程的地址空间中执行多个线程,但其共享进程系统资源和全局数据。
三. 如何控制线程?
1. Linux 支持的 POSIX 线程库
很早之前,还没有 Linux Kernel 的时候,是 Unix 的天下。Unix 是一款开源的系统,很多开发者都基于 Unix 做各种定制开发并开源出来,一时间各种类 Unix 系统层出不穷,局面一度非常混乱。为了提升各版本系统的兼容性和支持跨平台开发,IEEE 发布了 POSIX 标准。POSIX 全称是 Portable Operating System Interface for Computing Systems,它定义了具备可移植操作系统的各种标准,其中关于线程的标准参考:pthreads。目前包括 Linux、Windows、macOS、iOS 等系统都是兼容或部分兼容 POSIX 标准的。
Linux 中与线程有关的函数被打包到动态库 /lib64/libpthread.so
里,由于是 POSIX 提供的库,所以绝大多数函数的名字都是以 “pthread” 打头的。
- 要使用这些库函数,需通过引入头文 <pthread.h>。
- 链接时要加上 “-lpthread” 选项。
2. 线程创建
POSIX 通过 pthread_create() 函数来创建线程,该函数的原型如下:
返回值:创建成功返回 0,失败直接返回错误码。系统函数一般都是成功返回 0,失败返回 -1,并把错误码保存在全局变量 errno 中。而 pthread 库的函数都是通过返回值直接返回错误码,虽然每个线程都有自己的 errno,但这是为了兼容其他函数接口而提供的,pthread库本身并不使用它,POSIX 认为通过返回值返回错误码更加清晰并且读取返回值的开销要比读取线程内的 errno 变量的开销要小。
参数说明:
① 当函数成功时,线程标识符保存在输出型参数 thread 指向的内存中,该参数的类型为pthread_t,代表线程ID。
② 参数attr中含有初始化线程所需要的属性。如果不指定对象的属性,将其置为NULL,表示创建一个默认的线程,其属性为非绑定的、未分离的、有一个默认大小的堆栈,具有和父进程一样的优先级。
③ start_routine 是线程入口函数的地址,该函数有一个void* 类型的参数并且返回一个void* 类型的值。当 start_routine 这个函数返回时,相应的线程也就结束了。
④ arg 表示要传递给 start_routine 函数的参数,类型为void*。
函数说明:
如果在一个线程中调用 pthread_create() 创建新的线程后,当前线程从 pthread_create() 返回继续往下执行,而新的线程执行代码由 strat_routine 函数指针决定。strat_routine 函数指针接受一个 void* 类型的参数,是通过 pthread_create 函数的 arg 参数传递给它的,这个指针按什么类型解释由调用者自己定义。start_routine 返回值类型也是 void*,这个指针的类型同样由调用者自己定义。start_routine 返回时,这个线程就退出了,其他线程可以调用 pthread_join 得到 start_routine 的返回值,这类似于父进程调用 wait() 得到子进程的退出状态一样。
函数使用举例:
在主线程(main 执行流)中使用 pthread_create() 创建一个新线程,之后主线程和新线程都使用 while 循环每隔一秒打印一句话。
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
void* Routine(void* arg)
{
while(1)
{
printf("--------------- I am %s\n", (const char*)arg);
sleep(1);
}
return NULL;
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, (void*)"thread 1");
while(1)
{
printf("I am main thread\n");
sleep(1);
}
return 0;
}
编译运行:
ps -aL
命令
使用ps -aL
命令可以查看当前会话中所有线程的属性信息:
标志 | 含义 |
---|---|
PID | 进程ID |
LWP | 轻量级进程ID(注意不是线程ID) |
TTY | 登入者的终端机位置,若为远程登入则使用动态終端介面 (pts/n) |
TIME | 使用了的 CPU 时间,注意,是实际花费掉的 CPU 运作的时间,而不是系統时间 |
CMD | 就是 command 的缩写,产生此线程的指令 |
线程ID、LWP、线程组ID
1、线程 ID 就是 pthread_create() 函数传入的第一个参数,它的类型为 pthread_t,实际上是一个无符号长整数类型的重定义:
typedef unsigned long int pthread_t;
线程ID是 POSIX 线程库设置的在用户角度唯一标识线程的编号。 pthread 库把线程ID提供给用户,用户拿到线程ID后可以使用 pthread 库里的其它线程控制函数对这个线程进行删除、等待、分离等一系列操作。即 POSIX 线程库标识线程的是线程ID。
2、LWP 全拼light weight process
即轻量级进程ID,它的本质是该线程 task_struct 结构体里的 pid 变量,LWP 是站在内核的角度唯一标识线程的。
3、每个线程都是一个线程组里的一个成员,线程组把多个线程集合到在一起,线程组可以同时对其中的多个线程进行操作。在生成线程时,必须将其放在指定的线程组中,也可以放在默认的线程组中,默认组就是生成该线程所在的线程组。一旦一个线程加入了某个线程组,就不能再被移出这个组。
- 主线程会默认会自己创立一个线程组,线程组ID等于主线程的 LWP。
- 其它在这个主线程之下直接或间接创建的新线程默认和主线程同属一个线程组。
- 默认情况:进程ID = 主线程的LWP = 线程组ID
在每一个线程的 task_struct 里都存有它所在线程组的线程组 ID,叫做tgid,全称 thread group ID:
4、三者关系总结
pthread_self()和线程ID的含义
pthread_create 函数成功返回后,新创建的线程ID被填写到第一个参数所指向的内存单元中。区别一下进程 ID 和线程 ID:
- 进程 ID 的类型是 pid_t,每个进程 ID 在整个系统中是唯一的,调用 getpid() 可以获得当前进程 ID,是一个正整数值。
- 线程 ID 的类型是 pthread_t,它只在当前进程中保证是唯一的,在不同系统中 pthread_t 这个类型有不同的实现,它可能是一个整数值、结构体,甚至可能是一个地址,所以不能简单地当成整数而使用 printf 打印,调用 pthread_self() 可以获得当前线程 ID。
函数原型:pthread_t pthread_self(void);
返回值:返回调用线程自己的线程ID
在 Linux 中线程 ID 的含义是指向该线程私有资源的首元素地址:
我们让主线程和新线程分别调用 pthread_self() 函数拿到它自己的线程 ID,并用 printf 以地址 %p 的格式分别打印它们自己的线程ID:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
void* Routine(void* arg)
{
while(1)
{
printf("----------------------- I am thread 1,tid is:%p\n", (void*)pthread_self());
sleep(1);
}
return NULL;
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, NULL);
while(1)
{
printf("I am main thread,tid is:%p\n", (void*)pthread_self());
sleep(1);
}
return 0;
}
编译运行:
3. 线程等待
POSIX 中使用 pthread_join() 函数来等待线程退出,该函数的原型如下:
参数说明:
- thread:想要等待的线程 ID。
- retval:如果该参数不为 NULL,则将线程退出码放在 retval 所指向的内存中。实际使用时我们可以创建一个 void* 类型的变量,然后把该变量取地址传入。
返回值:等待成功返回 0,失败返回错误码。线程 ID 为 thread 的线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
- 如果 thread 线程在 main 函数中通过 return 返回,retval所指向的单元里存放的是线程退出时 return 的返回值。
- 如果 thread 线程是被别的线程调用 pthread_ cancel() 给终止了,retval 所指向的单元里存放的是常数 PTHREAD_ CANCELED,这是个宏定义,可以在头文件 pthread.h 中找到它:#define PTHREAD_ CANCELED ((void*)-1)。
- 如果 thread 线程是调用 pthread_exit() 自己退出的,retval 所指向的单元存放的是传给 pthread_exit() 的参数。
- 如果对 thread 线程的终止状态不关心,直接传 NULL 就行。
函数说明:
① 调用该函数的线程将被挂起等待,直到线程 ID 为 thread 的线程终止为止。
② thread 指定的线程必须在当前进程中,同时,thread 指定的线程必须是非分离的。
③ 不能有多个线程等待同一个线程终止。如果出现这种情况,一个线程将成功返回,别的线程将返回错误码 ESRCH。
函数使用举例
我们创建三个新线程,在主线程中使用 pthread_join() 等待这三个新线程退出并打印它们的退出码:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void* Routine(void* arg)
{
for(int i = 0; i < 3; ++i)
{
printf("I am %s,runing\n", (const char*)arg);
sleep(1);
}
return (void*)123;
}
int main()
{
pthread_t tid[3];
const char* str[3] = {"thread 1", "thread 2", "thread 3"};
// 1、循环创建三个线程
for(int i = 0; i < 3; ++i)
{
pthread_create(&tid[i], NULL, Routine, (void*)str[i]);
}
// 2、等待三个子线程退出
for(int i = 0; i < 3; ++i)
{
void* status = NULL;
pthread_join(tid[i], &status);
printf("%s quit,exit code is %d\n", str[i], (int)status);
sleep(1);
}
return 0;
}
编译运行:
问题1:为什么需要线程等待?
- 已经退出的线程,其资源没有被释放,需要其它线程等待它退出然后清理它的资源。
- 一个线程需要知道另一个线程把任务完成的怎么样了,这时需要通过等待来获得另一个线程的退出码。
问题2:如何得到被异常终止的线程的退出状态?
一个执行流终止分三种情况:
- 正常终止,结果正确。
- 正常终止,结果错误。
- 异常终止,导致整个进程崩溃。
前两种正常终止的情况可以通过最终等待得到的返回值来判断结果到底是正确还是错误。而异常终止的话可以通过收到的终止信号来分析异常出现的原因。
在进程中,父进程可以通过waitpid函数传入输出型参数得到子进程的退出状态,甚至如果子进程异常退出,父进程也可以得到导致子进程退出的信号。那么一个线程异常退出,同组的其他线程能不能拿到导致线程异常退出的信号呢?答案是不能的,因为一个线程异常会导致整个线程组的所有线程都退出,即同组的线程想要分析导致那个线程异常终止的信号时,自己也被操作系统清理了。
4. 线程终止
如果只需要终止某个线程终止而不是终止整个进程,可以有以下三种方法:
① 从线程 main 函数的 return 中返回,这种方法对主线程不适用,因为在主线程中 main 函数的 return 返回相当于调用 exit 函数终止进程。
② 一个线程可以调用pthread_cancel函数终止同组的其他线程。
③ 线程调用pthread_exit函数自我终止。
线程终止方式 | thread_join函数中第二个输出型参数最终的值 |
---|---|
非主线程调用return | return的返回值 |
phread_cancel(tid) | 常数PTHREAD_ CANCELED,即(void*)-1 |
pthread_exit((void*)返回值) | 传给pthread_exit的参数 |
注意事项:
- 在有多个线程的情况下,如果是主线程从main函数return返回,会导致整个进程退出。如果其他线程使用pthread_cancel()终止主线程或主线程自己调用pthread_cancel()函数自我终止, 那么主线程的状态变更成为Z, 其他线程不受影响。
- pthread_exit或者return返回的指针变量所指向的内存单元必须是全局的或者是用malloc堆空间上的,注意不能在线程函数的栈上分配,因为当其他线程得到这个返回指针时之前那个线程的生命周期已经结束了。
问题:线程可不可以用exit只终止自己?
答:不可以,exit函数不论是使用在主线程上还是子线程上,它的作用都是终止掉整个进程。
下面我们创建一个子线程,然后在子线程中调用exit函数:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <stdlib.h>
void* Routine(void* arg)
{
printf("I am %s\n", (const char*)arg);
exit(0);
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, (void*)"thread 1");
while(1)
{
printf("I am main thread\n");
sleep(1);
}
return 0;
}
编译运行,本来应该一直循环printf打印的主线程,因为新线程调用exit函数导致整个进程终止,所以主线程也终止了:
4.1 非主线程调用return仅终止自己
创建的三个新线程都使用return正常退出,在主线程使用pthread_join等待并接收他们的退出码:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void* Routine(void* arg)
{
for(int i = 0; i < 3; ++i)
{
printf("I am %s,runing\n", (const char*)arg);
sleep(1);
}
return (void*)123;
}
int main()
{
pthread_t tid[3];
const char* str[3] = {"thread 1", "thread 2", "thread 3"};
// 1、循环创建三个线程
for(int i = 0; i < 3; ++i)
{
pthread_create(&tid[i], NULL, Routine, (void*)str[i]);
}
// 2、等待三个子线程退出
for(int i = 0; i < 3; ++i)
{
void* status = NULL;
pthread_join(tid[i], &status);
printf("%s quit,exit code is %d\n", str[i], (int)status);
sleep(1);
}
return 0;
}
编译运行:
4.2 pthread_cancel()
一般是其他线程调用这个函数来终止线程ID为thread的线程,而不是自己调用来终止自己。另外被杀死线程的退出码是常数PTHREAD_ CANCELED,即(void*)-1。
返回值:成功返回0,失败返回错误码。
参数:想要终止的线程ID。
函数使用举例:
创建新三个线程,在主线程中使用pthread_cancel杀死这个三个线程并获取它们的退出码:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void* Routine(void* arg)
{
while(1)
{
printf("I am %s,runing\n", (const char*)arg);
usleep(1000);
}
return (void*)123;
}
int main()
{
pthread_t tid[3];
const char* str[3] = {"thread 1", "thread 2", "thread 3"};
// 1、循环创建三个线程,并使用pthread_cancel杀死创建的三个线程
for(int i = 0; i < 3; ++i)
{
pthread_create(&tid[i], NULL, Routine, (void*)str[i]);
pthread_cancel(tid[i]);
}
// 2、主线程阻塞等待获取它们的退出码
for(int i = 0; i < 3; ++i)
{
void* status = NULL;
pthread_join(tid[i], &status);
printf("%s quit,exit code is %d\n", str[i], (int)status);
}
return 0;
}
编译运行,发现三个新线程的退出码是常数PTHREAD_ CANCELED,即(void*)-1,而不是return返回的(void*)123。因为这三个新线程是被主线程用pthread_cancel()终止的,而不是正常顺序执行return返回的。
4.3 pthread_exit()
该函数用于线程自我终止
- 如果当前线程是非分离的,那么这个线程的退出码retval将被保留,直到其他线程用pthread_join来等待当前线程终止并获取到它的退出码retval。
- 如果当前线程是分离的,退出码retval将被忽略,该线程的所有资源被系统收回。
返回值:无返回值,跟进程一样,线程结束的时候退出码无法返回到它的调用者(自身)。
参数:若retval不为空,该线程的退出码将被置为retval这个参数的值。
函数使用举例:
创建三个新线程,然后在它们的执行函数里仅打印一句话后调用pthread_exit进行自我终止。主线程等待并获取它们的退出码:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
// 线程printf打印一句话后就调用pthread_exit终止自己
void* Routine(void* arg)
{
printf("I am %s,runing\n", (const char*)arg);
pthread_exit((void*)123);
}
int main()
{
pthread_t tid[3];
const char* str[3] = {"thread 1", "thread 2", "thread 3"};
// 1、循环创建三个线程
for(int i = 0; i < 3; ++i)
{
pthread_create(&tid[i], NULL, Routine, (void*)str[i]);
}
// 2、主线程等待并获取它们的退出码
for(int i = 0; i < 3; ++i)
{
void* status = NULL;
pthread_join(tid[i], &status);
printf("%s quit,exit code is %d\n", str[i], (int)status);
}
return 0;
}
编译运行:
5. 线程分离
默认情况下,新创建的线程是不分离的,线程退出后,同组的其它线程需要对其进行pthread_join等待操作,否则无法释放资源,从而导致系统资源泄漏。如果不关心线程的返回值,等待就是一种负担,这个时候,我们可以考虑让这个线程分离,即告诉系统,当这个线程终止时,操作系统可以直接回收这个线程的资源。
POSIX 使用 pthread_detach 来完成线程分离的,它的函数原型如下:
返回值:成功返回0,失败返回错误码。
参数:要分离线程的线程ID,可以自己分离自己,也可以分离其他线程。
函数说明:
① 可以是线程组内其他线程对目标线程进行分离,也可以是线程自我分离。
//分离自己
pthread_detach(pthread_self());
//分离其他线程
pthread_detach(其他线程的线程ID);
② join和分离是冲突的,不能等待一个已经分离的线程。
③ 不能多次调用pthread_detach分离同一个线程,这样的结果是不可预见的。
④ 分离的线程依然在同一地址空间运行,只不过被分离线程的退出状态不被其他同组线程所关心,但如果被分离的线程是异常退出,为了安全,操作系统还是会把整个进程给销毁。
函数使用举例
被分离的线程依然和其它同组线程共用同一个进程地址空间。分离的意思只是同组的其他线程不关心这个被分离线程的死活和它的退出状态了,但如果被分离线程异常退出的话,其它同组的所有线程也将崩溃。
下面代码我们在新线程的执行函数中把自己分离,然后故意除0使得这个新线程异常崩溃,观察主线程是否也会跟着一起崩溃:
#include <stdio.h>
#include <pthread.h>
void* Routine(void* arg)
{
pthread_detach(pthread_self());
printf("I am %s,runing\n", (const char*)arg);
int a = 10/0;
pthread_exit((void*)123);
}
int main()
{
// 1、创建一个新线程
pthread_t tid;
pthread_create(&tid, NULL, Routine, (void*)"thread 1");
// 2、等待新线程退出并获取它的退出码
void* status = NULL;
pthread_join(tid, &status);
printf("thread 1 quit,exit is:%d", (int)status);
return 0;
}
编译运行,发现整个进程都崩溃了: