一. 什么是线程?
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函数指针接受一个参数,是通过pthread_create函数的arg参数传递给它的,该参数的类型为void*,这个指针按什么类型解释由调用者自己定义。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库里的线程控制函数接口对特定线程进行删除、等待、分离等操作。
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的类型是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线程通过return返回,retval所指向的单元里存放的是退出线程return的返回值。
- 如果thread线程被别的线程调用pthread_ cancel()异常终止,retval所指向的单元里存放的是常数PTHREAD_ CANCELED,其值为宏定义,可以在头文件pthread.h中找到它的定义:#define PTHREAD_ CANCELED ((void*)-1)。
- 如果thread线程是自己调用pthread_exit()自我退出的,retval所指向的单元存放的是传给pthread_exit()的参数。
- 如果对thread线程的终止状态不感兴趣,可以传NULL给retval参数。
函数说明: ① 调用该函数的线程将挂起等待,直到线程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"};
for(int i = 0; i < 3; ++i)
{
pthread_create(&tid[i], NULL, Routine, (void*)str[i]);
}
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. 线程终止
如果只需要终止某个线程终止而不是终止整个进程,可以有以下三种方法:
??① 从线程函数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"};
for(int i = 0; i < 3; ++i)
{
pthread_create(&tid[i], NULL, Routine, (void*)str[i]);
}
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"};
for(int i = 0; i < 3; ++i)
{
pthread_create(&tid[i], NULL, Routine, (void*)str[i]);
pthread_cancel(tid[i]);
}
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>
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"};
for(int i = 0; i < 3; ++i)
{
pthread_create(&tid[i], NULL, Routine, (void*)str[i]);
}
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", arg);
int a = 10/0;
pthread_exit((void*)123);
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, Routine, (void*)"thread 1");
void* status = NULL;
pthread_join(tid, &status);
printf("thread 1 quit,exit is:%d", (int)status);
return 0;
}
编译运行,发现整个进程都崩溃了:
|