马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?立即注册
×
一、线程到底是什么:从"资源分配单位"讲起
要理解线程,得先回到进程的一个基本属性:进程是操作系统分配资源的基本单位——独立的地址空间、打开的文件描述符、信号处理器、内存映射……创建和维护这些都要付出时间和空间成本。
正因为进程"重",只靠进程来的并发粒度太粗。于是有了线程。
Linux 设计者在这里做了一个很务实的取舍:如果再专门为线程设计一套全新的数据结构和调度算法,整个内核会复杂到难以维护。所以 Linux 走了一条"复用"的路子。
进程 = 资源容器(地址空间 + 各类内核资源)
线程 = CPU 调度的基本单位(执行流)
更准确地说:在 Linux 上,所有可调度执行流都叫轻量级进程。线程和进程在内核里并没有本质区别,共同的结构体都是 task_struct;CPU 调度时压根不用区分"这是进程还是线程",反正都是 task_struct,都是一个执行流。
那线程到底"轻"在哪?答案就一句话:线程共享进程的资源,但要有一份独立的执行上下文。
二、线程共享什么,又独享什么
同一个进程里的多个线程,跑在同一个虚拟地址空间上:
- 共享:代码区、全局数据区、堆、以及打开的文件描述符表、信号处理设置、当前工作目录、用户凭证等。
- 各自独享:线程 ID(
pthread_t)、独立的栈(stack)、寄存器上下文(尤其是程序计数器 PC 和栈指针 SP)、errno、调度优先级。
所谓"让不同线程看到进程地址空间中代码区的不同代码、并执行不同的代码",实现手段就是:给每个线程一份独立的栈(以及独立的寄存器上下文),但共用同一张页表级映射下的地址空间。不同线程从地址空间的不同入口开始跑,就自然形成了"多执行流"的效果。
这也解释了线程的两面性:共享带来了通信便利(全局变量、共享内存直接可达),也带来了同步难题(数据竞争、需要锁)。这是理解多线程编程的起点。
三、内核视角:task_struct 里装着什么
每个线程都有一个与之关联的 task_struct 结构体,里面包含:
- 进程/线程状态信息(运行、可中断睡眠、停止、僵尸等)
- 调度信息(优先级、调度策略、CPU 亲和性)
- 资源使用情况(占用的内存、打开文件数、CPU 时间)
- 指向地址空间(
mm_struct)、文件系统、文件描述符表等的指针
因为线程和进程共用 task_struct,所以从内核调度器看过去,它们长得一模一样——这就是"轻量级进程"这个叫法的由来。
四、页表:内存管理的基本单位是 4KB
先记住一个数字:内存和磁盘上的数据块,基本大小都是 4KB(page,页)。
为了管理内存中每一块 4KB 的区域,操作系统内核里有一个描述该内存块的数据结构;一片 4GB 的内存就有 4GB / 4KB = 1048576 个这样的块。用数组把它们组织起来,整个内存的管理就退化成对数组的增删查改——这是内核内存管理最朴素的思路。
所以:操作系统进行内存管理的基本单元是 4KB(页)。
五、页表不是一张大表,而是两级(乃至多级)
很多人以为页表就是一整张薄薄的表,其实在 32 位地址的语境下它是分层的:页目录(page directory)+ 页表(page table)。
一个虚拟地址有 32 位,拆成三段:
| 位段 | bits 0–11 | bits 11–21 | bits 21–31 |
| 名称 | 页内偏移 | 页表项下标 | 页目录项下标 |
| 作用 | 定位 4KB 块里的具体哪个字节 | 定位页表里哪一项 | 定位页目录里哪一个页表 |
具体机制:
- 高 10 位(典型实现是 bits 22–31)用来表示处于页目录的哪一个位置,页目录里可以存放若干(1024 个)页表的首地址。
- 中间 10 位(bits 12–21)用来表示处于哪一个页表,一个页表里也有若干(1024 个)页表项。
- 低 12 位定位到该 4KB 数据块中的具体字节。12 位刚好能表示
2^12 = 4096 = 4KB 个状态,所以低 12 位也被叫做页内偏移。
地址转换就是"三级查表":虚拟地址 → 页目录找到页表 → 页表找到页框号(物理页)→ 拼上页内偏移 = 物理地址。
页表项里还有权限位
如果再在页表项中加入访问权限审查、是否被使用过等等标志位,就能对用户的内存访问做审查与拦截,保证内存访问的安全性。常见的标志位包括:
- present / valid:这一项是否有效
- read/write:这一页是否可写
- user/superuser:用户态能不能访问
- executed/disabled:是否禁止执行(这个位就是后来 CPU 安全加固的关键一环,用来堵"栈上执行代码")
- accessed / dirty:是否被访问过、是否被修改过(供换页算法判断能不能直接丢弃)
换句话说,你写的那段内存越权访问会被拦下,靠的就是页表项里的这些比特,而不是靠什么额外的检查代码。
六、虚拟地址到物理地址:多线程是怎么被"撑"起来的
按上面这套分页存储模式,我们写的函数编译完之后,就是一块一块的虚拟地址。根据函数名就能找到函数入口地址,各个函数被分配到不同的虚拟地址,也就落到了页表区域的不同位置。
关键点在这里:
同一进程的不同线程 → 共用同一套页表(同一地址空间)
切换线程时 → 只切换栈指针 + 寄存器上下文,页表基准(mm)不变
切换进程时 → 连页表基准一起换(TLB 要刷新)
给不同的线程分配不同的栈区(以及独立的执行入口),就能让不同的线程访问到页表的不同子集,进一步就能访问到不同的代码——多线程技术就是这么落地的。
反过来想,如果每个线程都用独立的一套页表、独立的一套地址空间,那它就是"另一个进程"了。Linux 干脆不区分:线程只是一个共享 mm 的 task_struct。
顺带理解一下 TLB
每次地址转换都查两级/多级页表,性能代价不小,所以 CPU 里有个 TLB(快表)缓存最近用过的地址映射结果。切进程要刷 TLB,切线程不用——这也是"线程切换比进程切换便宜"的一个重要来源。
七、pthread_create 的四个参数到底在说什么
落到代码层面,pthread_create 的原型是:
int pthread_create(pthread_t *thread,
const pthread_attr_t *attr,
void *(*start_routine)(void *),
void *arg);
四个参数逐个拆开:
thread:用来回传新线程的 ID
这是一个指向 pthread_t 类型的指针,用于获取新创建线程的线程 ID。调用 pthread_create 之后,这个指针会被设置为新线程的 ID。后面 pthread_join、pthread_detach 都要用它。
attr:线程属性
这是一个指向 pthread_attr_t 类型的指针,用于设置线程属性,如栈大小、优先级等。传 NULL 就使用默认属性——大多数场景都应该传 NULL,不需要自己折腾。
start_routine:线程的入口点
一个函数指针,指向线程开始执行时要调用的函数,通常称为线程的"入口点"或"启动例程"。它的返回值是 void*、参数也是 void*,这个约定就是为了让你能传任意类型的数据进去、也能让线程返回任意结果。
arg:传给启动例程的参数
用于向线程的启动例程传递参数,可以通过这个参数向线程传递任何类型的数据。如果启动例程不需要参数,就传 NULL。
#include <pthread.h>
#include <iostream>
void* newthread(void* arg)
{
while (true) {
std::cout << "hello from thread" << std::endl;
}
return NULL;
}
int main()
{
pthread_t tid;
pthread_create(&tid, NULL, newthread, NULL);
pthread_join(tid, NULL);
return 0;
}
编译时需要链接 pthread:g++ main.cpp -o app -lpthread。上面这个循环是个死循环,只为演示"线程里持续执行"这件事,实际写代码一定要有退出条件,否则线程永远不结束。
八、三个必须记住的配套结论
- 线程是 CPU 调度的基本单位,进程是资源分配的基本单位。Linux 上两者都体现为
task_struct,线程也叫轻量级进程。
- 4KB 是内存管理的基本单元,页表是分层的(页目录 → 页表 → 页内偏移),虚拟地址到物理地址靠逐级查表,页表项里的权限位承担了访问审查。
- 多线程共享地址空间但各自有栈,这既省了创建成本,也带来了同步问题——共享数据就必须配锁。
把"进程管资源、线程管执行、页管内存"这三件事串起来,再看 pthread_create,就不会觉得它只是一串函数调用了。
(本文由 qidao123.com企服评测-应用市场 编辑提示整理:示例基于 Linux 2.6+ 内核的 NPTL 线程模型与 x86 两级页表,代码示例为 POSIX 标准用法,适用于 GCC / G++ 环境。) |