Linux 进程与线程的区别与联系
一、什么是线程
理解:"进程"有着自己完整的资源(比如代码数据、各个地址的分布...)。线程好比进程内的一个个分支,可以调度'进程'内部的资源,来执行对应的分支任务——类比'厂房'中的'工人'利用进程的内部资源来执行不同的任务。之前学习的进程好比一个执行流,也就是一个线程,全部任务都由这个执行流完成;线程越多,该进程的效率越高,对应越复杂,风险越高!
本质:线程是共享进程资源的实体;进程就是分配系统资源的实体。
概念:线程是进程的执行单元(或执行流),共享进程的资源、独立运行,本质是'轻量化进程'。
二、进程与线程的切换效率
如果要切换一个进程:
需要销毁对应的全部数据,最终也是去除 PCB 结构,然后重新加载新的结构数据,形成新的 PCB。
尤其是'热数据',这是进程间切换导致效率低的一大'痛点':最小都有几千 KB。
'热数据'是需要被高频读取、使用的数据,比如全局变量、函数这些,进程为了避免每次访问都需要去重新加载,就将这些'热数据'放在了 Cache(CPU 高速缓存区)供 CPU 快速找到该类型的数据。
'热数据'对应着'冷数据',即哪些访问频率很低的数据,'热数据'需要跟随进程的访问情况随时替换。
如果要切换一个线程:
线程是和进程共享资源的,线程的退出不需要去换进程 PCB 这些,只有进程的退出才去释放 PCB,所以线程的替换与退出最极端也是加载一些新的代码数据到 CPU 执行,影响很低。
三、虚拟到物理地址的转换
在之前我们知道进程地址空间的虚拟地址转换到物理地址需要借助中间的页表,今天我们再深入!
比如现在有一个 32 位的虚拟地址:X00000001000000010000000100000001
此时虚拟地址会被按照 10、10、12 位被分隔开:0000000100 0000010000 000000000001
前 10 位对应**'页目录索引';中间 10 位对应'页表索引';后 12 位对应'页内偏移'**。
先用前 10 位确定一级页表的位置,一级页表里面存着二级页表的起始位置(2^10=1024)。
中间 10 位再确定二级页表的具体位置,二级页表中存内存中对应页框位置(2^10=1024)。
最后 12 位再确定页框中具体的字节位置((2^12=4096=4KB))。
(后 12 位:操作系统规定'一页内存的大小是 4KB'(4096 字节),而 4096 = 2¹²,所以需要 12 位二进制才能表示'一页内的所有位置'(0 到 4095)。比如上面的后 12 位 000000000001 换算成十进制是 1,意思是'在这一页的第 1 个字节位置'。)
四、线程库的认识
首先需要知道系统是提供了'线程库'的,即线程的系统调用接口,但是后来由于线程库的部分接口很复杂,用户二次封装,出现了用户层面二次封装的'线程库',我们主要学习用户层面的'线程库'。
注意:Linux 中是没有明确的'线程'的概念,只有'轻量化进程'的概念,因此上面的线程内核接口实质是轻量化进程的接口。该线程原库名为**'Pthread 库'**,几乎所有 Linux 平台都已默认携带。
五、线程使用基本常识
- 任何一个线程如果出现错误会导致进程同时关闭。
- 线程是进程的执行分支,如果单个线程出现异常,操作系统也会向进程发送信号,导致该进程和所有线程关闭。
- 既然线程是进程的执行分支,因此进程退了,该进程的所有线程自然也就崩了。
- 不能使用 exit() 终止线程,否则会直接导致整个进程结束,它是用来终止进程的。
六、线程库接口
(1)pthread_t
该接口用于返回系统成功创建线程的 ID,我们需要创建一个变量接收,例如:tid。
pthread_t tid;
(注意:此时只是获取了线程 ID 类型,并没有创建出来。)
此时 tid 就接收了底层系统调用返回的线程 ID。
(2)pthread_create()
原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
返回值:成功返回 0;失败返回错误码(注意错误原因以及错误码都是线程库里面的)。
作用:创建一个线程。
参数:
- **第一个参数:**获取的线程 ID 地址。
- **第二个参数:**设置线程的属性(这个后面再谈,先设置为 NULL)。
- **第三个参数:**线程要执行任务的函数。
- 函数的返回值是
void *(可以返回任意类型的数据,最后要强制转换)。
- 函数的参数是
void *(可以给函数传任意类型的数据,传之前要强制转换)。
- **第四个参数:**函数参数。
理解:参数
下面我通过讲解普通函数(无参)、普通函数(一个参数)、传对象来教学如何使用这两个接口:
普通函数(无参):
假如现在有这样一个线程的任务函数:
void* thread_task(void* arg) {
std::cout << "Hello pthread" << std::endl;
return NULL;
}
现在如果要利用 pthread_create() 创建线程调用该函数,应该这样使用:
解读:第三个参数由系统调用的函数指针指向任务的函数。
第四个参数为任务函数的参数(指针类型),既然不需要函数参数就传 NULL。
普通函数(有参):
假如现在有这样一个线程的任务函数,需要传递一个字符串作为参数:
void* thread_task(void* arg) {
std::cout << (char*)arg << std::endl;
return NULL;
}
解读:该函数的参数需要先强转为 void*,保持与函数原型一致(传参和函数的参数都必须是 void*),随后在使用的时候再强转回来。
结构体参数:
假如有下面这样一个结构体对象:(不管怎样第三个参数只能是函数)
struct Person {
Person(const char* ptr, int age) :_ptr(ptr),_age(age) {}
const char*_ptr;
int _age;
};
void* thread_task(void* arg) {
std::cout << (((struct Person*)arg)->_ptr) << std::endl;
std::cout << (((struct Person*)arg)->_age) << std::endl;
return NULL;
}
解读:不管是传什么参数,都要求指针且必须强转为 void 类型*,与函数原型一致,随后使用参数的时候再强转回来。
理解:返回值
返回值的原理都是一样的:先强转为(void*)指针返回,外面需要有一个接收参数的(void)指针,同时需要告诉线程返回值存放的位置*,拿到之后再强转回来使用。
struct Person {
Person(const char* ptr, int age) :_ptr(ptr),_age(age) {}
const char*_ptr;
int _age;
};
void* thread_task(void* arg) {
struct Person* ptr=(struct Person*)arg;
return (void*)new struct Person(ptr->_ptr,ptr->_age);
}
(3)pthread_join()
原型:
#include <pthread.h>
int pthread_join(pthread_t thread, void **retval);
参数:
thread:需要等待的子线程 ID(由 pthread_create 函数返回)。
retval:用于存储子线程的返回值(即子线程函数 return 的指针)(无返回值就填 NULL)。
返回值:
作用:阻塞等待子线程结束并获取其返回值(回收资源)。
(4)pthread_exit()
原型:
#include <pthread.h>
void pthread_exit(void *retval);
参数:指向线程返回值的指针,用于传递线程的退出状态。
作用:当前线程会立即停止执行,退出并进入'终止状态',并可通过 pthread_join 获取返回值。
(5)pthread_cancel()
原型:
#include <pthread.h>
int pthread_cancel(pthread_t thread);
参数:目标线程的 ID。
作用:向目标线程发送取消请求,其核心是'请求'而非'强制终止'。
(6)pthread_self()
原型:
#include <pthread.h>
pthread_t pthread_self(void);
作用:获取当前线程的 ID(标识符)(类似 getpid())。
补充:我们还可以通过指令 ps -La 获取所有线程 ID(PID 代表进程,LWP 代表线程)。
七、性质验证
我先形成验证需要的代码:给线程开辟堆空间,用容器来存储结构体指针(形成三个线程为例)。
#include<unistd.h>
#include<iostream>
#include<pthread.h>
#include<vector>
#define MAX 3
class My_Pthread {
public:
My_Pthread(pthread_t id, int name) :_id(id),_name(name) {}
pthread_t _id=0;
int _name;
};
void* handle(void* arg) {
sleep(1);
My_Pthread* ptr=(My_Pthread*)arg;
return NULL;
}
int main() {
std::vector<My_Pthread*> pthread_pointer;
for(int i=1;i<=MAX;i++) {
pthread_t id;
My_Pthread* ptr=new My_Pthread(id,i);
pthread_pointer.push_back(ptr);
pthread_create(&id,,handle,(*)ptr);
ptr->_id=id;
}
( i=;i<MAX;i++) {
(pthread_pointer[i]->_id,);
}
( i=;i<MAX;i++) {
pthread_pointer[i];
}
;
}
(1)验证:堆空间共享
创建一个全局的堆空间,然后每个线程填入自己的 ID。
结果如下:多个线程可以访问同一块堆内存区域。
(2)验证:栈空间各自开辟
在线程函数内部设置一个变量,修改某一个线程的变量数据,看是否后面线程随着更改。
结果如下:每个线程拥有独立的栈空间,互不影响。
(3)验证:主线程可访问线程数据
在线程函数内部修改数据,外面用主线程访问:线程 3 的_date 数据。
结果如下:主线程可以通过共享内存访问线程数据。
(4)__thread 关键字
__thread 的本质是让变量成为'线程私有':
全局声明的 __thread 变量,会为每个线程分配独立的内存空间(副本)。
线程内部对该变量的读写,操作的是自己的副本,与其他线程完全隔离。
变量的生命周期与线程一致(线程创建时初始化,线程结束时自动销毁)。
理想效果:每个线程访问数据段中的 date 全局变量时,都是从 9000 开始访问。
测试结果:每个线程拥有独立的变量副本。