本篇文章主要对线程的概念和线程的控制进行了讲解。其中我们再次对进程概念理解。同时对比了进程和线程的区别。希望本篇文章会对你有所帮助。
文章目录
一、线程概念
1、1 什么是线程
1、2 再次理解进程概念
1、3 轻量级进程
二、进程控制
2、1 创建线程 pthread_create
2、2 线程与进程资源
2、3 线程id
2、4 获得线程id pthread_ self
2、5 线程等待 pthread_join
2、6 线程终止 pthread_exit、pthread_cancel
2、6、1 pthread_exit
2、6、2 pthread_cancel
2、7 线程分离 pthread_detach
三、总结
🙋♂️ 作者:@Ggggggtm 🙋♂️
👀 专栏:Linux从入门到精通 👀
💥 标题:线程控制💥
❣️ 寄语:与其忙着诉苦,不如低头赶路,奋路前行,终将遇到一番好风景 ❣️
一、线程概念
1、1 什么是线程
在一个程序里的一个执行路线就叫做线程(thread),线程在进程内部运行。什么是执行线路呢?怎么是在进程内部运行的呢?下面我们通过进程进行理解。
1、2 再次理解进程概念
我们之前学的进程是:进程就是内核数据结构+代码,同时每个进程都有自己独立的内核数据结构,以保持进程的独立性。具体如下图:
现在我们用了特定的技术。只创建进程控制块PCB(task_struct),而不再创建对应的地址空间和页表进行映射。我们新创建的进程控制块PCB(task_struct)让它指向我们已经存在的进程的地址空间。具体如下图:
正如上图所示,所有的进程控制块PCB(task_struct)共享了大部分资源。而这些资源均来自于我们第一个创建的进程控制块PCB(task_struct)。
上图的每个进程控制块PCB(task_struct)执行时,都是用的同一块进程地址空间。而每个进程控制块PCB(task_struct)可称之为线程。我们现在再来理解:在一个程序里的一个执行路线就叫做线程这个概念就不难理解了。其实就是一个 task_struct 所对应的运行起来后就是一个执行流。透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。
此时可能会有点疑惑:之前的进程和现在的线程有什么区别呢?
进程是独立的执行单元,拥有独立的内存空间,包括代码段、数据段和堆栈,因此进程之间的数据不共享。而线程是进程内的执行单元,多个线程共享同一个进程的内存空间,包括代码和数据。具体也可结合下图理解:
如上图所示,现在的进程是包含了多个进程控制块PCB(task_struct),和其对应的内数据结构。我们之前所学的进程里面只有一个执行流,而现在就不同了。 一个进程内最少有一个执行流,也就是一个进程内部最少有一个线程(主线程)。我们在主线程(最初的进程控制块PCB(task_struct),也可理解为第一个进程控制块PCB(task_struct))內部可创建多个新线程(也就是创建进程控制块PCB(task_struct))。
站在用户的角度,我们理解进程:进程=内核数据结构+对应的代码和数据。站在内核的角度,我们理解进程:承担分配系统资源的实体。
1、3 轻量级进程
在CPU调度中,只会对进程控制块PCB(task_struct)进行调度。并不会关心是一个进程或者线程。我们也可以认为CPU进行调度的单位是线程。在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。为什么呢?
它们与传统进程相比具有较小的资源占用和更快的创建、切换以及通信速度。轻量化进程具有以下特点:
共享地址空间:不同于传统进程拥有独立的地址空间,轻量化进程与父进程共享相同的地址空间。这样可以减少内存开销和减少上下文切换的开销。
轻量级创建和销毁:由于轻量化进程与父进程共享地址空间,创建和销毁的开销较小,因为无需分配新的地址空间和重复加载代码段等操作。
快速上下文切换:由于轻量化进程共享相同的地址空间,所以在进行线程之间的切换时,不需要切换页表,从而使得上下文切换速度更快。
共享资源:轻量化进程可以通过共享内存等机制方便地进行线程间通信和共享数据,避免了复杂的进程间通信机制带来的开销。
并发执行:轻量化进程可以在多个CPU核心上并发执行,充分利用多核处理器的性能。
所以在Linux下,进程控制块又称之为轻量级进程(Lightweight Process)。
二、进程控制
上面我们了解了进程的概念后,我们接下来看看在Linux怎么创建进程,和对进程的一系列操作。
2、1 创建线程 pthread_create
在学习pthread_create之前,我们先了解一下第三方库。本片文章不再讲解Linux操作系统提供了一种创建线程的接口。选择使用第三方库pthread来实现创建线程的一系列操作。pthread库提供了更多功能和跨平台的能力,使得多线程编程更加便捷和灵活。而且大部分语言底层封装的就是第三方库pthread。
pthread_create函数是pthread库中用于创建线程的函数。下面是对pthread_create函数使用的详细解释:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,void *(*start_routine) (void *), void *arg);
- thread:指向pthread_t类型变量的指针,用于存储新线程的标识符。在成功创建线程后,该指针将被填充。
- attr:指向pthread_attr_t类型变量的指针,用于设置新线程的属性。可以为NULL,表示使用默认属性。
- start_routine:指向一个返回类型为void*、接受一个void*参数的函数指针。该函数是新线程的起始点,线程将从这个函数开始执行。
- arg:传递给start_routine函数的参数。它是一个void*类型的指针,可以传递任何类型的数据,通常用于向新线程传递参数。
返回值的含义:
- 返回值为0:表示线程创建成功。
- 返回值为正整数:表示线程创建失败,具体的返回值通常对应不同的错误情况,可以使用errno来获取错误码并查看具体错误信息。常见的错误码包括:
- EAGAIN:当前系统资源不足,无法创建线程。
- EINVAL:传递给pthread_create函数的参数无效。
- EPERM:没有足够的权限来创建线程。
下面我们看一个实际的例子:
#include<iostream> #include<pthread.h> #include<unistd.h>using namespace std;void* fun(void* name) {cout << (char*)name << ", pid: " << getpid() << endl; }int main() {pthread_t id;int n=pthread_create(&id,nullptr,fun,(void*)"new thread 1");sleep(1);cout << "main thread" << ", pid: " << getpid() << endl;return 0; }
我们上面就是简单创建了一个线程,然后进行打印不同的内容和进程id。运行结果如下图:
我们发现,他们的进程id确实是相同的。也就我们上述所说的线程在进程内部运行。
我们再看下一段代码:
int x = 100;void show(const string &name) {cout << name << ", pid: " << getpid() << " " << x << "\n"<< endl; }void *threadRun(void *args) {const string name = (char *)args;while (true){show(name);sleep(1);} }int main() {pthread_t tid[5];char name[64];for (int i = 0; i < 5; i++){snprintf(name, sizeof name, "%s-%d", "thread", i);pthread_create(tid + i, nullptr, threadRun, (void *)name);sleep(1); // 缓解传参的bug}while (true){cout << "main thread, pid: " << getpid() << endl;sleep(3);} }
上述代码就是创建了多个线程,去执行同一个函数。同时打印一个全局变量和进程id。我们看运行结果:
我们也不难发现,线程之间确实有共享的一部分数据。上述例子中的全局变量就被所有线程(执行流)共享。那么线程如何看待进程内部的资源呢?
2、2 线程与进程资源
我们知道,线程大部分资源是与进程共享的。同时线程也是拥有属于自己的资源。那么到底有哪些资源共享,有哪些资源私有呢?
在Linux下,线程与进程之间共享的资源有以下几种:
内存空间:线程和进程都可以访问相同的内存空间,包括代码段、数据段和堆栈段。这意味着线程可以读取和修改相同的变量和数据结构,而不需要进行显式的通信。
文件描述符:每个进程都有一张文件描述符表,用于跟踪它们打开的文件。当一个线程打开或关闭文件描述符时,其他线程也可以通过相同的文件描述符进行访问。
信号处理器:进程中的信号处理器对所有线程可见,当一个线程接收到信号时,所有线程都可以对其进行处理。
共享库和代码段:共享库和可执行文件的代码段可以被多个线程共享。这意味着不同的线程可以同时执行相同的函数或方法。
其他系统资源:还有一些其他资源如进程ID、进程组ID、用户ID等,在一个进程中创建的线程也会继承这些属性。
每个线程私有的资源主要包括以下几种:
栈:每个线程都有自己的栈空间,用于保存函数调用、局部变量和返回地址等信息。
寄存器:线程使用寄存器来保存当前执行的上下文信息,包括程序计数器、栈指针等。
线程特定数据:线程可以使用线程特定数据(Thread-Specific Data,TSD)来存储每个线程独有的数据。这些数据在同一进程的不同线程之间是隔离的。
线程ID:每个线程都有唯一的线程ID,用于标识线程的身份。
错误号变量:每个线程有自己的错误号变量,用于保存最近的系统调用错误码。
2、3 线程id
细心的同学发现了,上述并没对线程创建的参数:pthread_t *thread 进行过多解释。那么pthread_t 是什么类型呢?其实 pthread_t 是一个 unsigned long int 类型的。又有什么用呢?
我们在Linux下可通过指令:ps -aL,来查看进程和线程资源。具体如下图:
其中我们看到有PID、LWP。LWP(Lightweight Process)所对应的就是线程的id。PID与LWP相等的就是主线程。这里的LWP与pthread_t *thread是一样的吗?我们不妨打印一下看看pthread_t *thread的值。如下图:
事实上,这里所说的 thread 与我们上述将LWP的id值并不是相同的。pthread_t *thread到底指的是什么呢?取决于实现。对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。怎么是地址呢?我们接着往下看。
我们知道,线程的执行过程中需要保存和管理各自的局部变量、函数调用以及其他线程执行时需要的临时数据。每个线程是必须有自己的栈空间。如果没有独立的栈空间,那么每个线程在压栈的时候,数据就会混乱。但是进程地址空间只有一个栈空间。怎么保证每个栈都有独立的占空间呢?具体如下图:
为了保证每个线程有独立的栈空间,在每当创建一个线程的时候,都会在共享内存区为线程创建一个独立的的struct pthread,当中包含了对应线程的各种属性,包括栈空间。每个线程都有自己的线程局部存储,当中包含了对应线程被切换时的上下文数据。而这部分数据是有线程库给我们创建和维护的。
每一个新线程在共享区都有这样一块区域对其进行描述,怎么找到这块空间呢?于是当创建成功后,就会把该块空间的起始地址进行返回,而pthread_t *thread就是接受的该地址!!!pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
- 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
- pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID。
2、4 获得线程id pthread_ self
pthread_self()函数是POSIX线程库中的一个函数,它用于获取当前线程的线程ID。该函数的定义如下所示:
pthread_t pthread_self(void);
在调用该函数时,它会返回一个用于表示当前线程的pthread_t类型的值。通常我们将这个值存储在一个变量中,以便后续使用。下面是一个使用pthread_self()函数的示例:
#include <stdio.h> #include <pthread.h>void* thread_func(void* arg) {pthread_t tid = pthread_self();printf("Thread ID: %lu\n", tid);// 执行其他操作...return NULL; }int main() {pthread_t tid;pthread_create(&tid, NULL, thread_func, NULL);pthread_join(tid, NULL); // 用来阻塞等待线程,回收资源 return 0; }
运行结果如下:
2、5 线程等待 pthread_join
线程等待是什么呢?与进程等待相似。线程在创建并执行的时候,线程也是需要进行等待的,如果主线程如果不等待,即会引起类似于进程的僵尸问题,导致内存泄漏。其主要原因是:已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。创建新的线程不会复用刚才退出线程的地址空间。
函数原型如下:
int pthread_join(pthread_t thread, void **value_ptr);
参数说明:
- thread:要等待的线程的标识符,类型为pthread_t。
- value_ptr:指向一个指针的指针,用于存储被等待线程的返回值。返回值通过该指针间接传递给调用者。
返回值:
- 成功返回0;失败返回错误码
函数功能:
- 当调用pthread_join时,会阻塞当前线程,直到指定的线程完成其执行,并返回其返回值。
- 如果线程已经结束,那么pthread_join会立即返回。
- 当一个线程终止后,它的返回值会被保留起来,并且可以由其他线程使用pthread_join进行获取。
我们先来看一下其使用,稍后会解释返回值的情况,代码如下:
void* fun(void* name) {int cnt=5;while(true){cout << (char*)name << ", pid: " << getpid() << endl;sleep(1);if(!--cnt)break;} }int main() {pthread_t id;int n=pthread_create(&id,nullptr,fun,(void*)"new thread 1");(void)n;int* ret;pthread_join(id,(void**)&ret);cout << "main thread" << ", pid: " << getpid() << endl;return 0; }
直接看运行结果: 根据结果看到,时进行阻塞式等待。能不能像父进程等待子进程那样进行循环检测等待呢?答案是不能的。
那么接下来我们再看一下其value_ptr到底是什么,和它是怎么来的。
当我们使用pthread_create创建线程时,其返回值是void*。而pthread_join的第二个参数就是接受的线程结束的返回值。这也是其类型为void** 的原因。我们不妨通过代码来看一下。代码如下:
void* fun(void* name) {int cnt=1;while(true){cout << (char*)name << ", pid: " << getpid() << endl;sleep(1);if(!--cnt)break;}return (void*) 10; }int main() {pthread_t id;int n=pthread_create(&id,nullptr,fun,(void*)"new thread 1");(void)n;int* ret=nullptr;pthread_join(id,(void**)&ret);cout << "main thread" << ", pid: " << getpid() << endl;cout<< "return value: " << (int)ret << endl;return 0; }
注意:返回值是一个以指针的形式进行返回的。我们可以对其进行强制类型转换后打印,不可以对其进行解引用。否泽就会引起段错误。具体结果如下:
2、6 线程终止 pthread_exit、pthread_cancel
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
- 线程可以调用pthread_ exit终止自己。
一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
接下来我们看看其用法和细节都有哪些。
2、6、1 pthread_exit
pthread_exit是、用于终止当前线程的执行并返回一个特定的值。它可以通过调用pthread_exit来显式地结束线程,也可以在线程函数的返回语句中隐式调用。
下面是一个简单的示例,说明了pthread_exit的使用方法和其中的一些细节:
void* thread_function(void* arg) {int thread_arg = *(int*)arg;// 打印线程参数printf("Thread argument: %d\n", thread_arg);// 结束线程pthread_exit((void**)13); }int main() {pthread_t thread;int arg_value = 100;// 创建线程,并传递参数if (pthread_create(&thread, NULL, thread_function, (void*)&arg_value) != 0) {fprintf(stderr, "Failed to create thread.\n");return 1;}// 等待线程结束int* thread_result=0;int n=pthread_join(thread, (void**)&thread_result);(void)n;cout<<"thread_result : "<<(int)thread_result<<endl;printf("Thread finished.\n");return 0; }
在上述示例中,我们首先创建了一个线程(由pthread_create函数执行),并将参数arg_value传递给线程函数thread_function。在线程函数中,我们将打印出传递的参数值,然后通过调用pthread_exit函数来显式地终止线程的执行。
参数可以是任意类型的指针(void*),用于传递线程的退出状态。通常情况下,这个参数被用来告知父线程关于子线程执行的结果或者其他相关信息。当线程调用pthread_exit时,它会将退出状态作为返回值传递给等待它的父线程。
当线程终止时,它的资源会被自动释放,包括线程栈和线程局部变量等。同时,父线程也可以通过pthread_join函数来等待子线程的退出,并获取其退出状态。
下面我们来看一下上述的运行结果,具体如下图:
我们之前学过exit()函数用来终止当前运行的程序。但是exit()函数和 pthread_exit()函数是有所区别的。exit()函数是用来终止进程的。当我们在新线程中使用exit()函数,那么整个进程将会被终止掉。
2、6、2 pthread_cancel
pthread_cancel函数是用于取消线程的函数,它允许一个线程取消同一进程中的另一个线程的执行。pthread_cancel函数原型如下:
int pthread_cancel(pthread_t thread);
参数:
pthread_t thread
:目标线程的标识符,即要取消的线程。返回值:
- 成功:返回0。
- 失败:返回非0的错误码,表明函数调用失败的具体原因。
下面我们来看一个实际例子,来理解一下 pthread_cancel函数 的使用。
// 目标线程函数 void* threadFunc(void* arg) {std::cout << "Thread has started." << std::endl;// 模拟工作for (int i = 0; i < 10; ++i) {std::cout << "Working..." << std::endl;sleep(1);}std::cout << "Thread is finished." << std::endl;// 清理工作,释放资源pthread_exit(NULL); }int main() {pthread_t tid;// 创建目标线程if (pthread_create(&tid, NULL, threadFunc, NULL) != 0) {std::cerr << "Failed to create thread." << std::endl;return 1;}// 主线程等待一段时间sleep(3);// 向目标线程发送取消请求if (pthread_cancel(tid) != 0) {std::cerr << "Failed to cancel thread." << std::endl;return 1;}// 等待目标线程结束if (pthread_join(tid, NULL) != 0) {std::cerr << "Failed to join thread." << std::endl;return 1;}std::cout << "Main thread is finished." << std::endl;return 0; }
上述例子,我们就是使用了pthread_cancel来终止线程。当然,线程还没有运行结束时就对其进行终止。具体运行结果如下图:
能不能在线程的內部进行自己终止自己呢?代码如下:
pthread_cancel(pthread_self());
这是一个取消自身线程的操作。首先,取消自身线程可能会导致未完成的工作无法正常结束,尤其是当你的线程在执行某些关键任务时。这可能导致资源泄漏或数据不一致的问题。
其次,取消自身线程可能打破了线程安全的设计原则。如果其他线程依赖于你的线程的状态或结果,那么取消自身线程可能会导致这些线程的行为出现问题。因此,一般来说,推荐使用pthread_cancel函数取消其他线程而不是自身线程。这样可以更好地控制线程的取消操作,并确保线程能够优雅地退出,以避免可能的问题。
如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。
2、7 线程分离 pthread_detach
pthread_detach() 函数用于将指定线程标记为分离状态。当一个线程被标记为分离状态后,该线程的系统资源将在其退出时自动释放,无需其他线程调用 pthread_join() 来获取其返回状态。
下面是一个示例,演示了如何使用 pthread_detach() 函数将线程设置为分离状态:
void* thread_function(void* arg) {printf("子线程正在执行\n");sleep(3);printf("子线程执行完毕\n");return NULL; }int main() {pthread_t tid;if (pthread_create(&tid, NULL, thread_function, NULL) != 0) {printf("线程创建失败\n");return 1;}if (pthread_detach(tid) != 0) {printf("线程分离失败\n");return 1;}printf("主线程继续执行\n");sleep(5);printf("主线程执行完毕\n");return 0; }
在上述示例中,我们首先创建了一个新的线程,线程函数被设计为休眠3秒后退出。然后,我们调用 pthread_detach() 函数将线程 tid 标记为分离状态。之后,主线程继续执行并休眠5秒后退出。
由于我们将线程 tid 分离,因此不需要调用 pthread_join() 来等待子线程结束。相反,当线程 tid 执行完毕时,系统将自动回收其资源。
三、总结
当了解完线程的控制以后,我们先大概的总结一下线程的优缺点。
线程的优点:
- 创建一个新线程的代价要比创建一个新进程小得多。
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
- 线程占用的资源要比进程少很多。
- 能充分利用多处理器的可并行数量。
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点:
难以调试和协调:多线程程序因为涉及到共享资源的并发访问,会面临复杂的调试和协调问题。例如,线程间的竞争条件(Race Condition)会导致数据不一致或死锁等问题,这些问题难以定位和排查。
资源消耗:线程的创建和销毁需要消耗系统资源,包括内存和CPU时间片等。同时,线程之间的切换也会引入一定的开销。过多的线程数量可能会导致系统资源耗尽或降低整体性能。
容易出现同步问题:多线程程序在访问共享资源时需要进行同步操作,如加锁和解锁。而同步操作的过度使用可能导致性能下降,因为在执行同步操作期间,其他线程可能被阻塞等待资源释放。
可能引发安全问题:多线程程序中存在着线程间的竞争,如果没有正确处理竞争条件,可能会引发安全问题,如数据损坏、数据泄露等。
编程复杂性高:多线程编程相对于单线程编程来说更加复杂,需要考虑并发控制、同步机制等问题。编写高效且正确的多线程程序需要对并发编程概念和技术有深入的了解,对开发者的要求较高。
上述的线程缺点大部分来自于代码的问题。当然,线程的大部分问题对一个优秀的的程序员来说,并不是问题。
线程还有异常问题:单个线程如果出现除零,野指针等问题导致线程崩溃,进程也会随着崩溃。为什么呢?主要是因为线程共享进程的资源。
线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出
在一个进程中,多个线程共享相同的内存空间和其他系统资源。这意味着当一个线程发生崩溃时,它可能会影响到共享的资源。例如,如果一个线程遇到除零错误导致异常终止,它可能会导致相关的共享数据被破坏或变得不可用。
此外,操作系统为了保证进程的稳定性和安全性,在出现线程崩溃的情况下通常会终止整个进程。这是因为一个线程的崩溃可能会对其他线程产生意想不到的影响,进而导致进程无法继续正常运行。为了避免这种情况下可能出现的更严重的问题,操作系统会选择终止整个进程,以确保系统的稳定性。
那么上述我们也讲解了线程分离。如果线程分离后,线程出现错误导致崩溃后会引起整个进程进行崩溃吗?答案是会的!因为本质上他们还是在共享一个进程的资源。