【Linux】线程概念及控制
文章目录
- 线程的概念
- 什么是线程
- 线程的优点
- 线程的缺点
- Linux进程 VS 线程
- Linux进程控制
- POSIX线程库
- 创建线程
- 线程终止
- 等待线程
- 线程分离
- 线程ID及进程地址空间布局
线程的概念
什么是线程
线程(Thread)是计算机程序执行的最小单元,是进程(Process)中的一个实体。线程是操作系统调度的基本单位,它与进程共享进程的资源(如内存地址空间、打开的文件描述符等),但可以独立执行任务。
- 一切进程至少都有一个执行线程
- 线程在进程内部运行,本质是在进程地址空间内运行
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
- 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
从这张图可以看出,在Linux下, 进程 = 内核数据结构(task_struct
) + 可执行程序(代码和数据), 我们之前所讲的进程大多都是单线程。而在Linux下,线程可以理解为之前我们讲进程时的这一个个task_struct
。因为Linux的设计者认为,进程和线程都是执行流,具有极度的相似性,没必要单独设计数据结构和算法,直接复用进程的代码来模拟线程
线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 从寄存器的角度来看上下文切换,线程最多就是会比进程少做页表方面的切换,所以无论是线程还是进程,它们进行上下文切换的代价其实差不多。这里主要的差异是,因为CPU读取数据时,主要是从缓存当中去读取,进程切换时也会将缓存中数据进行替换,而线程是在进程内的,如果替换的是同一个进程中的线程,那么线程切换时,缓存中的数据其实不用改变。
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点
- 性能损失
- 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
- 健壮性降低
- 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
- 缺乏访问控制
- 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
- 编程难度提高
- 编写与调试一个多线程程序比单线程程序困难得多
- 线程异常
- 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃
- 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
- 如果主线程先退出,就代表进程退出了,此时其他的进程不管是否还在工作,也会跟着退出
Linux进程 VS 线程
- 进程是资源分配的基本单位
- 线程是调度的基本单位
- 线程共享进程数据,但也拥有自己的一部分数据:
-
线程ID
可以通过ps -aL
命令来查看
PID(Process ID):进程的唯一标识符。
LWP(Light Weight Process,轻量级进程,也常被称为线程 ID):在多线程环境中,用于标识一个线程。对于单线程进程,LWP 的值通常与 PID 相同;对于多线程进程,会看到同一个 PID 下有多个不同的 LWP 值,分别对应不同的线程。
TTY(Teletypewriter,终端类型):表示进程所关联的终端设备。
TIME(累计 CPU 时间):该进程已经使用的 CPU 时间,格式为小时:分钟:秒。
CMD(Command,命令):启动该进程的命令名称。 -
一组寄存器
-
栈
-
errno
-
信号屏蔽字
-
调度优先级
-
进程的多个线程共享 同一地址空间,因此文本段、数据段都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
进程和线程的关系如下图:
Linux进程控制
POSIX线程库
- POSIX 线程库是一套用于支持多线程编程的标准 API,定义在 IEEE POSIX.1c 标准中。它在 Unix/Linux 操作系统中广泛使用,也被许多其他平台支持。Pthreads 提供了线程创建、同步、调度等基本功能,使得程序能够充分利用多核处理器的并行计算能力。
- 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的
- 要使用这些函数库,要通过引入头文<pthread.h>
- 链接这些线程函数库时要使用编译器命令的 “
-lpthread
” 选项
先来看一下它们的函数有哪些,后面会慢慢讲到
创建线程
函数原型
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
参数说明
- thread:这是一个指向
pthread_t
类型的指针,用于存储新创建线程的标识符。通过这个标识符,可以在后续操作中对该线程进行管理,例如使用pthread_join
等待线程结束或使用pthread_detach
设置线程为分离状态等。 - attr:指向
pthread_attr_t
类型的指针,用于设置新线程的属性。如果设置为nullptr
,则表示使用默认属性创建线程。线程属性可以包括栈大小、分离状态、调度策略和优先级等。例如,可以使用pthread_attr_init
初始化一个线程属性对象,然后通过相关函数设置具体属性,最后将其传递给pthread_create
。不过我们一般使用默认属性创建线程就行了,所以这个参数一般传nullptr
就行了 。 - start_routine:这是一个函数指针,指向新线程要执行的函数。该函数的返回值类型为
void *
,参数类型为void *
。新创建的线程将从这个函数开始执行。 - arg:传递给
start_routine
函数的参数。这个参数可以是任何类型的数据,通过将其强制转换为void *
类型传递给线程函数,在线程函数内部再根据实际情况进行类型转换和使用。
返回值
- 如果线程创建成功,函数返回0。
- 如果创建线程失败,函数返回一个错误码,用于指示失败的原因。可以通过 strerror 函数将错误码转换为对应的错误信息字符串,以便进行错误诊断和处理。
线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数 return 。这种方法对主线程不适用,从 main 函数 return 相当于调用 exit,让整个进程直接退掉。
- 线程可以调用 pthread_ exit 终止自己。
- 一个线程可以调用 pthread_ cancel 终止同一进程中的另一个线程。
pthread_exit函数
原型:
void pthread_exit(void *retval);
参数说明:
- retval:是一个指向线程返回值的指针。这个返回值可以被其他线程通过 pthread_join 函数获取。
功能介绍:
- 当一个线程调用pthread_exit函数时,该线程会立即终止,并且系统会释放该线程所占用的资源,如线程栈等。不过需要注意的是,线程终止的顺序是不确定的,尤其是在多个线程同时运行的情况下。
- 例如,假设有一个简单的多线程程序,其中一个线程负责数据的读取,另一个线程负责数据的处理。如果读取线程在完成读取任务后调用pthread_exit,那么这个读取线程就会终止,而处理线程可以继续运行。
pthread_cancel函数
原型:
pthread_cancel(pthread_t thread);
参数说明:
thread
是要取消的目标线程的标识符,它是 pthread_t 类型,通过 pthread_create 函数创建线程时返回的线程标识符来指定需要被取消的线程。
功能介绍:
- 当调用
pthread_cancel
函数时,它会向目标线程发送一个取消请求。目标线程在收到这个请求后,会根据其当前的取消状态和取消类型来决定如何响应这个请求。 - 线程的取消状态可以是允许取消(PTHREAD_CANCEL_ENABLE)或者禁止取消(PTHREAD_CANCEL_DISABLE)。默认情况下,线程的取消状态是允许取消的。线程的取消类型有两种:延迟取消(PTHREAD_CANCEL_DEFERRED)和异步取消(PTHREAD_CANCEL_ASYNCHRONOUS)。默认是延迟取消。
- 在延迟取消模式下,线程会一直运行,直到它到达一个取消点。取消点是一些特定的函数,例如pthread_join、pthread_testcancel、sleep等函数,当线程执行到这些函数时,系统会检查是否有取消请求,如果有,则开始线程终止的流程。而在异步取消模式下,线程可能在任何时候被取消,这种方式比较危险,因为可能会导致资源泄漏或者数据不一致等问题。
等待线程
为什么要等待进程
- 避免资源泄漏:当线程结束时,其占用的系统资源(如栈空间、线程控制块等)需要被回收。如果主线程不等待子线程结束就直接退出,那么子线程的资源可能无法得到及时回收,从而导致资源泄漏。随着时间推移,这种泄漏可能会累积,影响系统的整体性能和稳定性,甚至可能导致系统崩溃。例如,在一个长时间运行的服务器程序中,如果频繁创建线程而不等待它们结束并回收资源,可能会逐渐耗尽系统的内存等资源。
- 保证数据一致性:在很多情况下,子线程可能负责处理一些与主线程或其他线程相关的数据或任务。如果主线程不等待子线程完成,可能会导致数据不一致或任务执行不完整。例如,一个线程负责将数据写入文件,另一个线程负责读取该文件并进行后续处理。如果写入线程还未完成写入操作,读取线程就开始读取,可能会读取到不完整或错误的数据,从而影响整个程序的正确性。
- 完成特定逻辑流程:有些程序的逻辑要求必须等待某个线程完成特定任务后,后续的流程才能继续进行。比如,一个线程负责从网络接收数据,另一个线程负责对接收的数据进行解析和处理。只有在接收线程成功接收完数据后,解析线程才能开始工作,否则解析操作可能会因为数据不完整而失败。在这种情况下,等待接收线程完成是确保整个程序逻辑正确执行的必要步骤。
- 获取线程执行结果和状态:通过等待线程(如使用
pthread_join
函数),可以获取子线程的返回值,从而了解子线程的执行结果。这对于判断线程任务是否成功完成以及根据结果进行相应的处理非常重要。例如,一个线程负责执行一个复杂的计算任务,主线程需要根据该线程的计算结果来决定后续的操作,如是否进行进一步的计算、输出结果或进行错误处理等。 - 处理线程执行错误:如果子线程在执行过程中出现错误,等待线程可以获取到错误信息(通过返回值或其他方式),以便主线程进行相应的错误处理,如记录错误日志、重新尝试任务或采取其他补救措施。如果不等待线程,可能无法及时发现和处理这些错误,导致程序出现不可预测的行为或隐藏的问题。
等待进程的方法
函数原型:
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
参数说明:
- thread:这是一个
pthread_t
类型的变量,用于指定要等待的线程。这个线程标识符通常是在创建线程时(通过pthread_create
函数)获得的。例如,在一个多线程程序中,假设 tid 是一个 pthread_t 类型的变量,表示一个已经创建的线程,那么在调用 pthread_join 时,就可以将tid作为第一个参数传递进去,如pthread_join(tid,…),这样就可以等待这个线程结束。 - retval:这是一个指向
void *
类型的指针,用于获取被等待线程的返回值。如果不需要获取返回值,可以将这个参数设置为 nullptr。当线程正常结束时,线程函数(在pthread_create
函数中指定的start_routine
函数)的返回值会通过这个指针返回给调用pthread_join
的线程。例如,假设线程函数返回一个指向动态分配数据结构的指针,那么在调用pthread_join
时,将一个合适的void *
类型的指针变量作为第二个参数传递进去,就可以获取到这个返回值。
基于上面所讲的线程创建与等待,我写一份测试代码:
#include <iostream>
#include <pthread.h>
#include <unistd.h>
// 定义一个类, 来演示可以传多个参数到 pthread_create 的线程处理任务函数(headler)中
class Task
{
public:
Task(int num1, int num2) : length(num1), width(num2) {}
double get_length() { return length; }
double get_width() { return width; }
~Task() {}
private:
double length;
double width;
};
// 定义一个类, 用来接收headler函数的返回值
class Result
{
public:
Result(double num) : ret(num) {}
void PrintRet() { std::cout << ret << std::endl; } // 打印返回值
~Result() {}
private:
double ret;
};
// 用来求一个矩形中最大园的面积
void *headler(void *args)
{
// 打印一句, 让我们看到生成的线程也在工作
sleep(1); // 错峰打印, 避免与主线程打印的结果冲突
std::cout << "I am default thread" << std::endl;
Task *pt = reinterpret_cast<Task *>(args); // 进行类型转换
// 选取较小的一边做为圆的直径
double diameter = pt->get_length() > pt->get_width() ? pt->get_width() : pt->get_length();
double ret = (diameter / 2) * (diameter / 2) * 3.14;
// 将结果保存并返回
Result* result = new Result(ret);
return result;
}
int main()
{
pthread_t tid;
// 将需要传递的参数通过 Task 实例化的对象传递过去
Task *ptask = new Task(5, 9);
pthread_create(&tid, nullptr, headler, ptask);
// 打印一句, 让我们看到主线程也在工作
std::cout << "I am main thread" << std::endl;
// 定义一个void *类型的指针, 用来接收 headler 的返回值
void *ret;
pthread_join(tid, &ret);
Result *pret = reinterpret_cast<Result*>(ret);
pret->PrintRet();
// 随手释放空间养成好习惯
delete ptask;
delete pret;
return 0;
}
运行结果如下:
这段代码很好的将上面两个函数用起来了,为了加深运用理解,我多创建了两个类,Task 类是用来使用 pthread_create
函数时,能够多传递几个参数。而 Result 类是用来使用 pthread_join
函数时,能够获取到线程执行任务的函数(headler)返回值。
总结如下:
调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过 pthread_join 得到的终止状态是不同的,总结如下:
- 如果 thread 线程通过return返回,retval 所指向的单元里存放的是 thread 线程函数的返回值。
- 如果 thread 线程被别的线程调用pthread_ cancel异常终掉,retval所指向的单元里存放的是常数 PTHREAD_ CANCELED。
- 如果thread线程是自己调用pthread_exit终止的, retval 所指向的单元存放的是传给 pthread_exit 的参数。
- 如果对 thread 线程的终止状态不感兴趣,可以传 nullptr 给 retval 参数。
线程分离
线程分离是一种线程管理机制。在默认情况下,线程是可结合(joinable)的,这意味着当线程结束时,它的资源(如栈空间、线程控制块等)不会自动释放,需要其他线程通过 pthread_join
函数来等待它结束并回收资源。而线程分离的目的是将线程设置为分离状态,处于分离状态的线程在结束时会自动释放自身的资源,不需要其他线程进行等待回收。
函数原型
#include <pthread.h>
int pthread_detach(pthread_t thread);
参数说明
- thread:此参数为
pthread_t
类型,代表需要设置为分离状态的线程标识符。这个线程标识符是在创建线程时通过pthread_create
函数获取的。例如,你已经使用pthread_create
创建了一个线程,并且将线程标识符存储在变量tid
(pthread_t tid)中,那么你可以通过pthread_detach
(tid) 来设置该线程为分离状态。也可以在新创建的线程里,通过pthread_self
来获取当前线程的tid
进行分离。
joinable和分离是冲突的,一个线程不能既是joinable又是分离的。
线程ID及进程地址空间布局
运行结果如下:
我们再来看这些线程的 LWP
显然,我们在程序中使用的 tid 和在外面看到的进程 ID 不是同一个东西,那么它们之间有什么关系呢?
在Linux中:
pthread_ create
函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。- 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
pthread_ create
函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID,原型:pthread_t pthread_self(void);
ps -aL
显示的LWP(lwd): ps -aL 命令输出中的 LWP(Light Weight Process,轻量级进程) ,也称为线程ID,是从内核角度看到的线程标识。在Linux内核中,线程是作为轻量级进程实现的,这些 LWP 是内核调度的实体。pthread_self
返回的线程ID(tid):pthread_self
函数返回的是线程在进程内部的地址空间中的一个标识符,它是由线程库(如NPTL - Native POSIX Thread Library)来维护的一个值,用于在进程内部区分不同的线程。
这两个值不一样主要是因为它们处于不同的层面,一个是内核层面的调度标识,一个是线程库在用户空间维护的用于在进程内部区分线程的标识。
那么 pthread_t
到底是什么类型呢?取决于操作系统的实现。对于Linux目前实现的NPTL实现而言,pthread_t 类型的线程ID,本质就是一个进程地址空间上的一个地址。
从这里也能够看出,线程也不是全部资源都共享,每个线程也有自己独立的属性,比如线程栈,还有线程局部存储,线程栈自然不用多说,这个肯定是独立的,不同的进程进入到相同的执行流,它们访问局部变量的地址是不一样的。这里可以讲一下线程局部存储。
线程局部存储(Thread Local Storage,简称TLS),也被称为线程本地存储或线程局部变量,是一种在多线程编程中用于为每个线程提供独立的变量存储区域的技术。
-
定义与作用
定义:线程局部存储是一种将数据与特定线程关联起来的机制,每个线程都有自己独立的存储空间,不同线程之间的局部存储数据相互隔离,互不干扰。
作用:它主要用于解决多线程环境下数据共享和并发访问的问题,避免了多个线程同时访问共享数据时可能产生的竞争条件和数据不一致性。 -
实现原理
存储结构:线程局部存储通常通过线程本地存储管理器来实现,该管理器维护了一个与线程相关联的存储区域,每个线程都有一个唯一的标识,通过这个标识可以访问该线程的局部存储区域。(如上图所示)
数据访问:当线程访问线程局部存储中的变量时,实际上是访问该线程独有的存储副本,而不是共享的全局变量。这样,每个线程都可以独立地读写自己的局部变量,不会影响其他线程的数据。 -
实现方式
- thread_local:
-
是 C++11 引入的标准关键字,用于声明线程局部存储。
-
具有良好的跨平台性,可在支持 C++11 及以上标准的编译器上使用,如 GCC、Clang、MSVC 等。
-
遵循 C++ 标准的行为,与其他 C++ 特性结合良好。
-
可以用于模板,例如:
-
- __thread:
- 是一个编译器扩展,在不同的编译器上可能有不同的实现细节和行为。
- 最早由 GCC 引入,其他编译器(如 Clang)也支持。
- 不是 C++ 标准的一部分,使用它可能会降低代码的可移植性。
- 通常只能用于普通的全局变量或静态变量,使用范围相对较窄。
- thread_local: