os笔记,学有所得:(同一进程内)多线程之间的内存空间是如何布局的?

2023年 8月 15日 45.0k 0

引入

从源码角度看linux中进程虚拟内存空间的管理

task_struct结构

struct task_struct {
      // 进程id
	    pid_t				pid;
      // 用于标识线程所属的进程 pid
	    pid_t				tgid;
      // 进程打开的文件信息
      struct files_struct		*files;
      // 内存描述符表示进程虚拟地址空间
      struct mm_struct		*mm;

      // .......... 省略 .......
}

进程、线程在linux内核中的描述符都是task_struct结构,其中的mm_struct结构,包含了进程虚拟内存空间的全部信息!每个进程都有唯一的mm_struct

fork调用时的函数调用链,揭示linux内核视角中的进程与线程

_do_fork : 为进程创建task_struct结构,并且用父进程资源填充结构体信息 ⇒ copy_process : 调用copy_xx函数开始继承父进程资源,着重关注copy_mm ⇒ copy_mm

static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
  // 子进程虚拟内存空间,父进程虚拟内存空间
	struct mm_struct *mm, *oldmm;
	int retval;

 //   ...... 省略 ......

	tsk->mm = NULL;
	tsk->active_mm = NULL;
  // 获取父进程虚拟内存空间
	oldmm = current->mm;
	if (!oldmm)
		return 0;

  //    ...... 省略 ......
  // 通过 vfork 或者 clone 系统调用创建出的子进程(线程)和父进程共享虚拟内存空间
	if (clone_flags & CLONE_VM) {
    // 增加父进程虚拟地址空间的引用计数
		mmget(oldmm);
    // 直接将父进程的虚拟内存空间赋值给子进程(线程)
    // 线程共享其所属进程的虚拟内存空间
		mm = oldmm;
		goto good_mm;
	}

	retval = -ENOMEM;
	// 如果是 fork 系统调用创建出的子进程,
	// 则将父进程的虚拟内存空间以及相关页表拷贝到子进程中的 mm_struct 结构中。
	mm = dup_mm(tsk);
	if (!mm)
		goto fail_nomem;

good_mm:
  // 将拷贝出来的父进程虚拟内存空间 mm_struct 赋值给子进程
	tsk->mm = mm;
	tsk->active_mm = mm;
	return 0;

 //     ...... 省略 ......
}
  • 如果是通过fork系统调用创建出子进程:

    • 首先会将父进程的虚拟内存空间 current->mm 赋值给指针 oldmm。

    • 然后通过 dup_mm 函数将父进程的虚拟内存空间以及相关页表拷贝到子进程的 mm_struct 结构中。

    • 最后将拷贝出来的 mm_struct 赋值给子进程的 task_struct 结构。

    也就是说,通过 fork() 函数创建出的子进程,它的虚拟内存空间以及相关页表相当于父进程虚拟内存空间的一份拷贝,直接从父进程中拷贝到子进程中

  • 如果是通过vfork或者clone系统调用创建出的子进程(线程):

    • 首先会设置 CLONE_VM 标识,这样来到 copy_mm 函数中就会进入 if (clone_flags & CLONE_VM) 条件中

    • 在这个分支中会将父进程的虚拟内存空间以及相关页表直接赋值给子进程。

    也就是说,父进程和子进程(线程)的虚拟内存空间就变成共享的了。也就是说父子进程之间使用的虚拟内存空间是一样的,并不是一份拷贝。

是否共享地址空间几乎是进程和线程之间的本质区别。Linux 内核并不区别对待它们,线程对于内核来说仅仅是一个共享特定资源的进程而已。

那么多线程之间是如何保证内存读写的安全呢?

(同一进程的)多线程之间是会出现竞态的,我们需要使用一些机制来保护多线程之间所共享的数据。

例如:互斥锁、原子操作、同步机制(信号量、条件变量)
但需要注意的是,栈区是需要保护的,如果多线程之间共享同一个栈区,那么会出现干扰的情况

例如:线程p1调用函数,压栈,紧接着线程p2调用函数,压栈;这时候线程p1结束调用,栈弹出!这显然是不合理的! 同一进程中的线程之间,它们的内存布局是怎么样的?

创建线程时的函数调用链

pthread_create : 分配线程的属性参数,例如线程栈大小等,这里涉及一个重要的结构 pthreadALLOCATE_STACK : 用来创建线程栈,实际是一个宏 ⇒ allocate_stack : 线程栈创建的核心代码

在内核中,每一个进程或线程都是通过task_struct结构体描述的,而在用户态(用户空间)也有一个用于维护线程的结构,它就是 pthread结构

allocate_stack函数的核心作用

  • 设置线程栈的大小
  • 防止线程栈访问越界,在栈末尾会设置一块 guardsize 区域,访问到此说明越界,发生崩溃
  • 线程栈实际上是在进程(可以认为是主线程)的堆区里创建的,并且有一个缓存对堆区分配线程栈做了优化;若缓存中找不到合适的,就通过mmap()创建一块新的(线程栈大小默认为2MB,超过了128KB)
  • 将线程的pthread结构分配到线程栈的栈底位置。
  • 计算出guard内存的位置,并设置这块内存是受保护的
  • 填充pthread结构的成员变量,例如stackblockstackblock_sizeguardsizespecific。这里的 specific 是用于存放 Thread Specific Data 的,即属于线程的全局变量
  • 将线程栈放入stack_used链表中组织起来,表示正在被使用;另一个管理线程栈的链表是stack_cache,也就是上述的缓存

小结

  • 多线程之间是共享进程(主线程)的内存空间的

    或许这也就是为什么在主线程退出后,其它线程无论是否运行完,程序都会终止吧!

  • 线程的栈是从进程的堆区分配的,它有stack_used、stack_cache进行管理

  • 线程仅拥有自己独立的栈

    线程也能从堆区中申请动态内存,但是堆区是共享的,申请动态内存需要互斥访问!

reference

极客时间 | 趣谈Linux操作系统

4.6 深入理解 Linux 虚拟内存管理 | 小林coding (xiaolincoding.com)

相关文章

服务器端口转发,带你了解服务器端口转发
服务器开放端口,服务器开放端口的步骤
产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
如何使用 WinGet 下载 Microsoft Store 应用
百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

发布评论