一,线程概念
1.1,什么是线程
1,进程 = 内核数据结构(多个线程+地址空间+页表) + 代码和数据,以前学习的进程,本质:内部只有一个线程的进程,进程内部只有
一个执行分支。
2,在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。
3,一切进程至少都有一个执行线程。线程在进程内部运行,本质是在进程地址空间内运行。
4,在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化。透过进程虚拟地址空间,可以看到进程的大部分资源,将
进程资源合理分配给每个执行流,就形成了线程执行流。
5,线程属于OS调度的基本单位。
1.2,虚拟地址和页表
(1),画图表示
注意:
1,cr3寄存器的高20位存放的是页目录表(4KB)所在的页框(4KB)的基地址(物理地址)。
2,页目录表和1024个页表都存放在物理内存里,一个页目录表(4KB)和1024个页表(1024 * 4KB)都刚好各自占据一个页框
(4KB)。
3,一个页目录项(PDE)占4个字节,一共32个比特位,其中高20位表示页表所在的页框的基地址,低12位是标志位。
位段类型 位号范围 名称 具体含义
地址段 31 ~ 12 PFN(物理页框号) 存储对应页表所在物理页的高 20 位基地址。
硬件自动将其左移 12 位(补 12 个 0),得到该页表的完整物理起始地址。
属性位 11 ~ 9 AVL(可用位) 硬件不使用,完全留给操作系统自定义,可用于标记页面置换策略、页表状态等。
属性位 8 G(全局位) 1 = 全局页,切换进程修改 CR3 时,该项对应的 TLB 缓存不会被刷新,常
用于内核页表。
属性位 7 PS(页大小位) 0 = 4KB 普通页,继续向下索引页表;
1 = 4MB 大页,该 PDE 直接指向 4MB 物理页,不再有下一级页表。
属性位 6 保留位 硬件保留位,软件必须置 0,不可随意使用。
属性位 5 A(访问位) 1 = 该页表覆盖的所有页面中,至少有一个被 CPU 访问过。
硬件自动置位,由操作系统软件清零,用于页面置换算法。
属性位 4 PCD(缓存禁止位) 1 = 禁止该页表对应的内存区域进入 CPU 高速缓存,常用于外设寄存器、
MMIO 映射场景。
属性位 3 PWT(写直达位) 控制该页表对应 Cache 的写策略:0 = 写回,1 = 写直达。
属性位 2 U/S(用户 / 管理员位) 1 = 用户态(3 环)可访问该页表覆盖的页面;0 = 仅内核态(0 环)可访问。
属性位 1 R/W(读写位) 1 = 该页表覆盖的所有页面可读可写;0 = 仅可读。
属性位 0 P(存在位) 1 = 对应页表在物理内存中,地址有效;
0 = 页表不在物理内存,访问会触发缺页异常(Page Fault)。
4,一个页表项(PTE)占4个字节,一共32个比特位,其中高20位表示对应页框的基地址,低12位是标志位。
位段类型 位号范围 名称 具体含义
地址段 31 ~ 12 PFN(物理页框号) 存储最终代码 / 数据所在物理页的高 20 位基地址。
与虚拟地址低 12 位的页内偏移拼接,得到最终的物理内存地址。
属性位 11 ~ 9 AVL(可用位) 同 PDE,操作系统自定义使用。
属性位 8 G(全局位) 同 PDE,全局页 TLB 不刷新。
属性位 7 PAT(页属性表位) 扩展内存缓存属性配置,配合 PCD、PWT 实现更细粒度的缓存策略,普通开发场
景极少使用。
属性位 6 D(脏位) PTE 独有。1 = 该物理页被写入修改过。硬件自动置位,软件清零;页面换出到磁
盘时,脏页必须写回,干净页可直接丢弃。
属性位 5 A(访问位) 1 = 该页面被 CPU 访问过;硬件自动置位,软件清零,用于页面置换算法。
属性位 4 PCD(缓存禁止位) 同 PDE。
属性位 3 PWT(写直达位) 同 PDE。
属性位 2 U/S(用户 / 管理员位) 同 PDE。
属性位 1 R/W(读写位) 同 PDE。
属性位 0 P(存在位) 同 PDE,标记数据页是否在物理内存中。
(2),具体数据结构
2.6.18内核用 struct page 结构表示系统中的每个物理页,出于节省内存的考虑,struct page中使用了大量的联合体union。
1,flags:用来存放页的状态。这些状态包括页是不是脏的,是不是被锁定在内存中等。flag 的每一位单独表示一种状态,所以它至少可以同时表示出 32 种不同的状态。这些标志定义在 <linux/page-flags.h> 中。其中一些比特位非常重要,如 PG_locked 用于指定页是否锁定,PG_uptodate 用于表示页的数据已经从块设备读取并且没有出现错误。
2,_mapcount :表示在页表中有多少项指向该页,也就是这一页被引用了多少次。当计数值变为 0 时,就说明当前内核并没有引用这一页,于是在新的分配中就可以使用它。
3,virtual :是页的虚拟地址。通常情况下,它就是页在虚拟内存中的地址。有些内存(即所谓的高端内存)并不永久地映射到内核地址空间上。在这种情况下,这个域的值为 NULL,需要的时候,必须动态地映射这些页。
要注意的是 struct page 与物理页相关,而并非与虚拟页相关。而系统中的每个物理页都要分配一个这样的结构体。
1.3,线程的优缺点
优点:
1,资源开销低,创建 / 销毁 / 切换速度快
创建进程需要完整复制父进程的地址空间、文件描述符表、信号处理上下文等,系统开销大;而线程共享进程的大部分资源,创建时只需要分配独立栈和少量寄存器上下文,内存占用、系统调用成本都远低于进程。
上下文切换时,线程不需要切换页表、不需要刷新 TLB(地址翻译缓存),只需要切换寄存器、栈指针,切换耗时通常是进程切换的几十分之一。
2,通信效率高,数据共享成本极低
多进程通信必须借助管道、消息队列、共享内存、套接字等 IPC 机制,往往需要内核态介入和数据拷贝;而线程共享同一块地址空间,全局变量、堆内存可以直接读写,不需要额外的数据拷贝,通信延迟远低于进程间通信。
这也是高并发后端服务常用多线程模型的核心原因之一。
3,并发粒度更细,程序响应性更好
单进程内可以同时运行多个线程,单个线程阻塞(如磁盘 IO、网络等待、sleep)时,其他线程可以继续执行,不会导致整个程序卡住。
典型场景:GUI 程序用主线程负责界面渲染,子线程处理耗时计算 / 网络请求,保证界面不卡顿;后端服务用多线程同时处理多个请求,避免 IO 阻塞浪费 CPU。
4,多核并行利用率高,轻量实现并行计算
多线程可以同时运行在不同 CPU 核心上,真正实现并行计算;相比多进程方案,它资源占用更少,适合 CPU 密集型的并行计算场景,比如数值计算、批量数据处理。
缺点:
1,存在线程安全问题,同步开销高,易引发并发 bug
因为共享地址空间,多个线程同时读写同一份数据时会产生竞态条件,导致数据不一致。必须通过互斥锁、信号量、条件变量、原子操作等同步机制保护临界资源,这会带来额外的性能开销。
更严重的是,同步逻辑编写不当极易引发死锁、活锁、优先级反转、数据撕裂等问题,这类 bug 通常偶现、难复现、难调试,是多线程开发的主要复杂度来源。
2,缺乏地址空间隔离,稳定性差
进程之间地址空间独立,一个进程崩溃不会影响其他进程;但线程共享同一块地址空间,任意一个线程出现野指针、数组越界、栈溢出等内存错误,都会直接破坏整个进程的内存空间,导致整个程序崩溃。
这也是高可靠服务(比如运营商核心系统、数据库服务)会倾向于多进程隔离架构的原因。
3,异常排查难度高
多线程环境下,bug 的触发和线程调度时序强相关,很多问题只在特定调度顺序下出现,复现和定位成本远高于单线程程序;同时多线程的调用栈、内存污染排查也更复杂。
4,线程数量并非越多越好,过量会带来反向开销
线程数超过 CPU 核心数后,线程上下文切换的开销会逐渐超过并发带来的收益,反而导致整体性能下降。因此实际工程中通常用线程池来限制线程数量,避免无节制创建线程。
5,进程级资源存在隐性冲突
信号处理、文件描述符、当前工作目录、全局变量等都是进程级共享的。一个线程修改了全局的信号处理函数、关闭了某个文件描述符,会影响进程内所有线程,容易引发隐性 bug。
1.4,线程异常,线程用途
因为并发问题,导致的问题:单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。
线程用途:合理的使用多线程,能提高CPU密集型程序的执行效率。合理的使用多线程,能提高IO密集型程序的用户体验。
2,进程与线程的资源分配
进程具有独立性,线程共享地址空间,线程共享进程资源。进程是资源分配的基本单位,线程是调度的基本单位。
线程私有数据:
1,线程ID(使用命令 ps -aL查看)。2,一组寄存器,线程有独立的硬件上下文数据。3,独立的栈结构。4,errno.5,信号屏蔽字,调度优先级。
进程的多个线程共享数据:
同一地址空间,代码段和数据段都是共享的。还共享文件描述符,信号处理方式,当前工作目录。
二,Linux线程操作接口
1,pthread_create 创建线程
参数:
thread:输出型参数,返回线程线程ID(tid)。
attr:设置线程的属性,attr为NULL表示使用默认属性。
start_routine:函数地址,线程启动要执行的函数。
arg:传给线程启动函数的参数。
返回值:
成功返回0,失败返回错误码。
#include<iostream>#include<pthread.h>#include<unistd.h>void*routine(void*str){while(true){printf("我是新线程,我的pid: %d, 我的tid是: %ld, 0x%lx, %s\n",getpid(),pthread_self(),pthread_self(),(char*)str);sleep(1);}}intmain(){pthread_t tid;intret1=pthread_create(&tid,nullptr,routine,(void*)"thread-01");if(ret1!=0){printf("pthread_create fail,errno: %d\n",errno);exit(1);}while(true){printf("我是主线程,我的pid: %d, 我的tid是: %ld, 0x%lx,我创建的新线程tid是: %ld, 0x%lx\n",getpid(),pthread_self(),pthread_self(),tid,tid);sleep(1);}return0;}
2,pthread_self 获取线程ID
3,pthread_exit 线程终止
参数:
retval:不能指向一个局部变量,出了作用域会销毁。
返回值:
无返回值。
4,pthread_cancel 取消线程
参数:
thread:线程ID。
返回值:
成功返回0,失败返回错误码。
5,pthread_join 线程等待
参数:
thread:线程ID。
retval:线程的返回值(接收pthread_exit的参数)。routine的返回值。
返回值:
成功返回0,失败返回错误码。
用法样例:
#include<iostream>#include<pthread.h>#include<unistd.h>#include<string>intcnt=0;void*routine(void*arg){while(true){printf("我是新线程tid: 0x%lx arg: ",pthread_self());std::cout<<(*(std::string*)arg)<<std::endl;sleep(1);++cnt;if(cnt==5)break;}std::string*ret=newstd::string("线程结束");*ret=*ret+", "+(*(std::string*)arg)+" tid: "+std::to_string(pthread_self());pthread_exit(ret);}intmain(){std::strings1("abcdefg");pthread_t tid;intret1=pthread_create(&tid,nullptr,routine,(void*)(&s1));if(ret1!=0){perror("create fail");}void*valret;intret2=pthread_join(tid,&valret);if(valret==PTHREAD_CANCELED)//#define PTHREAD_CANCELED ((void *) -1)printf("线程已被取消\n");elsestd::cout<<*((std::string*)valret)<<std::endl;return0;}6,pthread_detach 分离线程
(1),如果要知道线程的返回值,线程退出后,需要对其进行pthread_join操作,否则会造成资源泄漏。
(2),如果不想知道线程的返回值,也不想pthread_join回收,可以使用pthread_detach函数告诉系统,当线程退出时,自动释放线程资源。
(3),可以是线程自己分离,也可以是其他线程对目标线程进行分离。
(4),线程内可以使用fork()创建子进程。
(5),一旦线程要被设置为分离,主线程不能提前提出。
参数:
thread:线程ID。
返回值:
成功返回0,失败返回错误码。
三,线程ID和进程地址空间
(1),pthread_create函数的第一个参数是一个pthread_t类型的输出型参数,也就是线程ID,本质是进程地址空间上的一个虚拟地址,也就是动态库中创建的TCB(struct pthread)的起始地址。
(2),使用命令ps -aL看到的LWP也是线程ID,是属于进程调度的范畴,因为线程是轻量级进程,是操作系统调度的基本单位,所以需要一个数值来唯一表示该线程。
四,c++封装linux原生线程库
#pragmaonce#include<cstdio>#include<functional>#include<iostream>#include<pthread.h>namespacezsw{classthread{enumclassSTATUS{NEW_THREAD,//线程的状态,默认为NEW_THREAD;create成功之后才是RUN_THREADRUN_THREAD,JOINED_THREAD//已被join回收,不能再用tid操作};usingthread_task=std::function<void*()>;staticvoid*start_routine(void*pmthis){thread*self=static_cast<thread*>(pmthis);//只能对"当前线程"自己设置名字,并且名字最长15个字符(含\0限制16)if(!self->_name.empty()){std::string name=self->_name.substr(0,15);pthread_setname_np(pthread_self(),name.c_str());//np代表非标准}//返回值不存成员,统一由pthread_join回填,避免数据竞争returnself->_ttk();}public:thread(conststd::string&name,thread_task ttk):_name(name),_tid(0),_status(STATUS::NEW_THREAD),_ttk(ttk),_isdetach(false),_thread_ret(nullptr){}thread(constthread&)=delete;thread&operator=(constthread&)=delete;voidcreate(){if(_status!=STATUS::NEW_THREAD||!_ttk){printf("create fail: 线程已创建或无任务\n");return;}intret=pthread_create(&_tid,nullptr,start_routine,this);if(ret!=0){perror("create fail");return;//失败则保持NEW_THREAD,后续不能cancel/join}_status=STATUS::RUN_THREAD;}voidcancel(){if(_status==STATUS::RUN_THREAD){intret=pthread_cancel(_tid);if(ret!=0){perror("cancel fail");return;}//发出取消请求后线程仍是joinable,需由join回收}else{printf("线程不存在或已被回收,无法取消\n");return;}}void*join(){if(_status==STATUS::NEW_THREAD){printf("线程未创建,无法join\n");returnnullptr;}if(_isdetach){printf("线程已分离,无法join\n");returnnullptr;}if(_status==STATUS::JOINED_THREAD){printf("线程已join过\n");return_thread_ret;}//RUN/已退出未回收/被取消未回收 都能走到这里,由pthread_join同步取返回值intret=pthread_join(_tid,&_thread_ret);if(ret!=0){perror("join fail");returnnullptr;}_status=STATUS::JOINED_THREAD;if(_thread_ret==PTHREAD_CANCELED){printf("线程已被取消并回收\n");returnnullptr;//防把(void*)-1当业务指针解引用}printf("线程已join\n");return_thread_ret;}voiddetach(){if(_status==STATUS::RUN_THREAD&&_isdetach==false){intret=pthread_detach(_tid);if(ret!=0){perror("detach fail");return;}_isdetach=true;//成功才置位printf("线程分离成功\n");}else{printf("线程不存在或已分离\n");return;}}~thread(){}private:std::string _name;pthread_t _tid;STATUS _status;thread_task _ttk;//线程需要完成的任务bool_isdetach;//默认为false,代表没有分离线程void*_thread_ret;//线程退出时的信息,由pthread_join回填};}