Linux中线程和进程到底是什么关系?
在操作系统学习和后端开发面试中,“线程间共享哪些资源、又隔离哪些资源”是一个高频问题,很多人对此一知半解:只记得“线程共享进程地址空间”,但具体到哪些内存区域、哪些资源是共享的,哪些是线程私有,总是说不清楚。实际上,理解线程的资源共享模型,是理解并发编程、线程安全、进程线程关系的核心基础——很多并发bug的根源,就是错误地认为某个资源是线程私有,实际上它是共享的,或者反过来。
本文将从Linux内核的线程实现出发,结合进程地址空间的布局,梳理清楚线程间到底共享哪些资源、隔离哪些资源,帮你彻底理清这个核心问题。
先搞清楚:Linux中线程和进程到底是什么关系?
要讲清楚线程的资源共享,首先得明确Linux内核中对线程和进程的抽象——Linux和很多传统操作系统不一样,它没有专门的线程数据结构,内核用轻量级进程(LWP) 实现线程,所有进程和线程都用统一的task_struct来描述。
当我们调用fork()创建进程时,会创建一个新的task_struct,分配独立的地址空间(即独立的mm_struct,存储页表基址等地址空间信息);而当我们调用pthread_create()创建线程时,同样会创建一个新的task_struct,但它会共享父进程的mm_struct,也就是和创建它的进程共用同一个地址空间。
这就是“线程共享进程地址空间”这一说法的内核本源:同一个进程内的所有线程,对应的task_struct都指向同一个mm_struct,共用同一套页表,所以它们看到的地址空间是完全一样的。而不同进程的mm_struct是独立的,所以进程间地址空间是隔离的。
明白了这个底层设计,我们就可以结合进程地址空间的布局,从上到下逐一分析每个区域是不是线程共享的。
标准进程地址空间布局:各区域共享性分析
我们先回忆一下,一个Linux进程的用户地址空间从低地址到高地址,大致分为这几个区域:
代码段(.text):存放程序的二进制可执行代码
数据段:分为只读数据段(.rodata)、初始化可读写数据段(.data)、未初始化数据段(.bss)
堆:从低地址向高地址增长,动态分配内存(malloc底层从这里分配)
共享库区域:加载动态链接库(比如libc.so)的代码和数据
栈:用户栈,从高地址向低地址增长
环境变量/命令行参数:进程启动时传入的参数和环境变量
内核空间:内核地址空间,所有进程共享内核区域(用户态不能访问)
我们一个一个分析每个区域的共享性:
1. 代码段:所有线程共享,只读不写
代码段存放的是程序编译后的二进制指令,也就是CPU要执行的机器码,它本身是只读的——运行过程中不会修改,所以同一个进程内的所有线程共享同一份代码段完全没问题,不需要隔离。
实际上,每个线程调度执行的时候,就是从代码段中取指令执行,不同线程可以执行同一个函数,也可以执行不同的函数,共享代码段没有任何冲突。所以代码段肯定是线程共享的。
2. 数据段:全局变量静态变量都在这里,全部共享
数据段存放的是全局变量和静态变量,根据属性不同分为只读数据、初始化数据、未初始化数据,但不管哪一种,都属于进程地址空间的一部分,所有线程共享。
也就是说:一个全局变量,不管初始化还是未初始化,线程A修改了它的值,线程B再访问的时候看到的就是修改后的值,因为它们访问的是同一块内存地址。这也是全局变量为什么会有线程安全问题的根本原因——因为它是共享资源。
举个最简单的例子:
int global_cnt = 0; // 存在.data段,所有线程共享
void *thread_func(void *arg) {
for (int i = 0; i < 10000; i++) {
global_cnt++;
}
return NULL;
}
// 主线程创建两个线程,最后global_cnt大概率小于20000,就是因为共享变量的竞争问题
如果global_cnt是线程私有的,就不会出现这个问题,所以这个例子本身就证明了全局变量所在的数据段是线程共享的。
这里需要注意一个特殊情况:静态变量也是存放在数据段,不管是函数内的静态局部变量,还是文件内的静态全局变量,都是共享的,只要在同一个进程内,所有线程都能访问到同一个静态变量。
3. 堆:所有线程共享动态分配内存
我们调用malloc或者new从堆上分配的动态内存,这块内存是所有线程共享的——只要某个线程拿到了这块内存的地址,其他线程都可以访问这块内存,因为堆本身就在共享的地址空间里。
比如主线程调用malloc分配了一块内存,把地址传给子线程,子线程可以直接读写这块内存,不需要额外操作,这就是堆共享最直接的体现。所有基于堆内存的共享数据结构,比如链表、哈希表,能在多线程下使用,本质就是因为堆是共享的。
那很多人会问:堆是共享的,那malloc的锁是怎么回事?实际上,锁是用户态glibc为了保证堆分配的正确性加上去的,因为多个线程同时从堆分配内存,如果不锁会破坏堆的元数据结构,并不是因为堆本身是线程私有——锁是同步机制,不改变资源的共享属性,堆本身还是共享的,所有分配出来的内存都在同一个共享地址空间里。
4. 共享库区域:动态库代码数据,所有线程共享
动态链接库(比如libc.so、libpthread.so)的代码和数据都会被加载到进程地址空间的共享库区域,这个区域也是所有线程共享的,和进程自身的代码段、数据段一样。
这也很好理解,所有线程调用printf,本质都是执行同一个libc里的printf代码,访问同一个函数,自然是共享的。只有在程序启动加载动态库的时候,才会映射一次这个区域,所有线程共用。
5. 环境变量与命令行参数:进程启动时传入,全程共享
进程启动时,内核会把命令行参数和环境变量存放到进程地址空间的高地址区域,这块内容一旦初始化就不会修改(当然用户态也可以手动修改,但所有修改对所有线程可见),所以自然也是所有线程共享的,任何线程都能通过environ全局变量访问到所有环境变量,一个线程修改了环境变量,另一个线程就能看到修改后的值。
6. 用户栈:每个线程一个独立栈,线程私有
到这里,上面的所有区域都是共享的,那第一个线程私有区域就是用户栈。每个线程创建的时候,系统都会给它分配一块独立的栈空间,用来存储局部变量、函数调用栈帧、返回地址等信息,每个线程的栈是独立的,互不干扰。
为什么栈要线程私有?很简单,因为每个线程的函数调用链路是独立的:线程A调用func1,func1再调用func2,线程B调用func3,它们的栈帧不能互相覆盖,必须分开存储,所以每个线程要有自己独立的栈。
举个例子:
void func() {
int local = 0;
local++;
printf("%d\n", local);
}
void *thread_func(void *arg) {
func();
return NULL;
}
两个线程同时调用thread_func,最后输出都是1,不会变成2,就是因为每个线程的local局部变量都存在自己独立的栈上,是线程私有,互相不影响——如果栈是共享的,那两个线程的local就会互相修改,输出就乱了。
这里很多人会混淆一个问题:我们说“栈从高地址向低地址增长”,这个说的是每个线程自己的栈,整个进程地址空间中,每个线程的栈是分开的独立区域,每个线程只能访问自己的栈,不会访问到其他线程的栈,地址空间虽然共享,但是每个线程的栈在不同的地址范围,操作系统会给每个线程分配独立的范围,所以实际上是隔离的。
7. 内核栈:每个线程独立的内核栈,私有
除了用户栈,每个线程还有自己独立的内核栈。当线程从用户态陷入内核态执行系统调用的时候,需要用内核栈保存内核态的调用栈、寄存器上下文等信息,每个线程的内核执行流是独立的,所以每个线程都要有自己独立的内核栈,这也是线程私有的,不同线程的内核栈互不干扰。
内核栈的独立是操作系统调度的基础,每个线程被调度切换的时候,都会保存自己的内核栈上下文,切换回来的时候再恢复,所以必须每个线程一个。
8. 寄存器与线程上下文:每个线程独立,私有
线程切换的时候,需要把当前线程的寄存器上下文(比如程序计数器PC、通用寄存器的值)保存下来,切换到另一个线程的时候再恢复它的上下文,所以每个线程都有自己独立的寄存器上下文,这也是线程私有的——一个线程的寄存器值不会影响另一个线程。
9. 文件描述符表:进程打开的文件,所有线程共享
很多人这里会搞错:文件描述符表到底是共享还是私有?实际上,在Linux中,同一个进程内的所有线程共享同一个文件描述符表——你在一个线程中打开一个文件,得到一个文件描述符,其他线程都可以用这个描述符读写同一个文件,一个线程关闭了这个描述符,另一个线程再用就会出错,因为整个进程内的文件描述符表是共享的。
为什么要这么设计?因为很多场景下,一个线程打开socket,其他线程可以直接用这个socket读写,非常方便,不需要进程间传递描述符,符合设计预期。当然,文件描述符的偏移量是存在内核的file结构里的,也是所有线程共享的,所以多个线程同时读写同一个文件,偏移量会互相影响,这一点需要注意。
10. 信号处理函数:进程注册的信号处理,所有线程共享
信号处理函数是注册在进程层面的,同一个进程内所有线程共享同一个信号处理函数表——你给进程注册了SIGINT的处理函数,任何线程收到SIGINT都会执行同一个处理函数。
不过,每个线程有自己独立的信号掩码(blocked信号集合),也就是说你可以阻塞某个线程的某个信号,不影响其他线程,信号掩码是线程私有的。
11. 进程ID、用户ID、组ID:整个进程共享同一个ID
进程ID(PID)在Linux中,每个轻量级进程(线程)有自己的PID,但是从用户视角来看,整个进程对外是同一个PID,用户ID、组ID这些身份信息是所有线程共享的,一个线程修改了进程的用户ID,所有线程都会受到影响。
12. 页表、地址空间本身:所有线程共享
我们开头说过,同一个进程内的所有线程共享同一个mm_struct,也就是同一套页表,同一个地址空间布局,所以整个地址空间的映射关系是共享的,一个线程修改了页表(比如分配了一块新内存、修改了内存权限),其他线程也能看到这个修改。
一张表总结:共享vs隔离清晰对比
我们把上面的分析整理成表格,一目了然:
资源类型共享还是私有说明
代码段(.text)共享程序指令只读,所有线程共享
数据段(.data/.bss)共享全局变量、静态变量,所有线程共享
只读数据段(.rodata)共享只读常量,所有线程共享
堆共享malloc分配的动态内存,所有线程共享
共享库映射区共享动态链接库的代码和数据,所有线程共享
命令行参数、环境变量共享进程启动参数,所有线程共享
文件描述符表共享打开的文件、socket,所有线程共享
信号处理函数共享进程注册的信号处理,所有线程共享
进程ID、用户ID、组ID共享进程身份信息,所有线程共享
页表、地址空间结构共享同一个mm_struct,所有线程共享
当前工作目录共享进程的工作目录,一个线程修改后所有线程可见
用户栈私有每个线程独立栈,存储局部变量、栈帧
内核栈私有每个线程独立内核栈,内核态执行使用
寄存器上下文私有线程切换保存的寄存器,每个线程独立
线程局部存储(TLS)私有专门给线程存储私有数据的区域,每个线程有独立副本
信号掩码私有每个线程可以独立设置阻塞的信号,互不影响
错误码(errno)私有为了避免一个线程的错误码覆盖另一个的,errno默认是线程局部存储
为什么要这么设计?共享和隔离的权衡
理解了哪些共享哪些私有,我们再想想:操作系统为什么要这么设计?本质上是对性能和隔离性的权衡:
共享地址空间、堆、数据段这些资源,是为了让线程间通信更高效:同一个进程内的线程不需要跨地址空间就能访问共享数据,不需要内核介入就能直接通信,比进程间通信效率高很多,这就是“多线程共享地址空间”最大的优势。
隔离栈、寄存器上下文这些资源,是为了让每个线程能独立执行:每个线程有自己独立的执行流,函数调用不会互相干扰,切换的时候能正确保存和恢复上下文,这是多线程并发执行的基础。
线程局部存储(TLS)是对共享模型的补充:有些数据我们就需要它线程私有,比如errno,这时候可以把它放到TLS中,每个线程有自己独立的副本,互不干扰,满足特殊场景的需求。
这个设计非常精妙:需要通信共享的资源都共享,需要独立执行的资源就隔离,兼顾了通信效率和执行独立性,这也是线程比进程轻量的原因——创建线程不需要分配新的地址空间,只需要分配栈和上下文,开销比创建进程小很多,切换线程的时候,因为地址空间不变,不需要刷新TLB,切换开销也比进程小很多。
开发中哪些常见错误来自对共享性的误解?
很多并发bug,根源都在于对线程资源共享性的错误理解,最常见的两种错误:
1. 误以为全局变量是线程私有
很多初学者写代码,把全局变量当局部变量用,多个线程同时修改,最后导致数据竞争,出现意想不到的结果——实际上全局变量在数据段,是共享的,必须加同步措施才能访问。
2. 误以为堆分配的内存是线程私有
有人觉得“我在这个线程里malloc的内存,只有这个线程能用”,实际上堆是共享的,只要地址泄露出去,其他线程都能访问,所以堆上分配的数据结构如果被多个线程访问,同样需要加锁。
3. 误以为文件描述符是线程私有
一个线程打开了文件,用完就关了,另一个线程还在用这个描述符,结果就是EBADF错误——因为文件描述符是共享的,关闭对所有线程生效,这种bug在多线程网络编程中非常常见。
总结
线程资源共享的核心逻辑其实非常清晰:凡是支持多线程并发执行需要独立的资源,就是线程私有,包括用户栈、内核栈、寄存器上下文、线程局部存储;凡是属于进程本身、需要线程间共享通信的资源,都是共享的,包括代码段、数据段、堆、文件描述符表、地址空间本身。
理解了这个共享模型,你就能从底层理解为什么多线程会有线程安全问题——因为大部分核心资源都是共享的,多个线程同时读写共享资源就会出现竞争,所以才需要同步互斥机制;同时也能理解线程为什么比进程轻量——因为大部分资源都是共享的,创建和切换不需要复制切换这些共享资源,开销自然更低。
这个知识点是并发编程的基础,不管是操作系统学习还是后端面试,都是核心考点,理清了共享和隔离的边界,才能写出正确、高效的多线程代码。





