Charles Z.

进程、线程及其内存模型


这篇文章是一个大杂烩,内容来自网络和《深入理解操作系统》原书第二版 过后应该回头梳理并做一些深入的分析

进程提供给应用程序关键的抽象:

  1. 一个独立的逻辑控制流,它提供一个假象,好像我们的程序独占地使用处理器
  2. 一个私有的地址空间,它提供一个假象,好像我们的程序独占地使用存储器系统

从程序员的角度,我们可以认为进程总是处于下面三种状态之一:

  1. 运行。进程要么在CPU上执行,要么在等待被执行且最终会被内核调度
  2. 停止。进程的执行被挂起(suspend),且不会被调度。当收到SIGSTOP、SIGTSTP、SIDTTIN或者SIGTTOU信号时,进程就停止,比呢且保持停止直到它收到一个SIGCONT信号,在这个时候,进程再次开始运行(信号是一种软件中断的形式,将在后文描述)
  3. 终止。进程永远的停止了。进程会因为三种原因终止
    1. 收到一个信号,该信号的默认行为是终止进程;
    2. 从主程序返回;
    3. 调用exit函数。

创建子进程

  1. 子进程得到与父进程用户级虚拟地址空间相同的(但是独立的)一份拷贝,包括文本、数据和bss段、堆以及用户栈。
  2. 还获得与父进程任何打开文件描述符相同的拷贝。
  3. 父进程和新创建的子进程之间最大的区别在于它们有不同PID。
  4. fork函数调用一次,返回两次,父进程中,fork返回子进程的PID,子进程中fork返回0

进程地址空间

这篇文章总结的非常好,原文转发过来。

我们一般都知道,每个程序都能看到一片完整连续的地址空间,这些空间并没有直接关联到物理内存,而是操作系统提供了内存的一种抽象概念,使得每个进程都有一个连续完整的地址空间,在程序的运行过程,再完成虚拟地址到物理地址的转换。我们同样知道,进程的地址空间是分段的,存在所谓的数据段,代码段,bbs段,堆,栈等等。每个段都有特定的作用,仔细看下面这张图,就对进程地址空间中的划分有了清楚的了解了。

  1. 从0xc000000000到0xFFFFFFFF共1G的大小是内核地址空间(后面再探讨内核地址空间,先重点关注用户地址空间),余下的低地址3G空间则是用户地址空间。
  2. Code VMA: 即程序的代码段,CPU执行的机器指令部分。通常,这一段是可以共享的,即多线程共享进程的代码段。并且,此段是只读的,不能修改。
  3. Data VMA: 即程序的数据段,包含ELF文件在中的data段和bss段。
  4. 堆和栈: 这两个大家都十分熟悉了,new或者malloc分配的空间在堆上,需要程序猿维护,若没有主动释放堆上的空间,进程运行结束后会被释放。栈上的是函数栈临时的变量,还有程序的局部变量,自动释放。
  5. 共享库和mmap内容映射区:位于栈和堆之间,例如程序使用的printf,函数共享库printf.o固定在某个物理内存位置上,让许多进程映射共享。mmap是一个系统函数,可以把磁盘文件的一部分直接映射到内存,这样文件中的位置直接就有对应的内存地址。此处参考后面的第三条。
  6. 命令行参数: 程序的命令行参数
  7. 环境变量:类似于Linux下的PATH,HOME等环境变量,子进程会继承父进程的环境变量。

一些容易模糊的认知

  1. 对于32位的机器来说,虚拟的地址空间大小就是4G,可能实际的物理内存大小才1G到2G,意味着程序可以使用比物理内存更大的空间。
  2. execve函数在当前进程的上下文中加载并运行一个新的程序,它会覆盖当前进程的空间,但并没有创建一个新进程。新的程序仍然拥有相同的PID,并且继承了调用execve函数时已经打开的所有文件的描述符。
  3. 一个常见的错误是“假设堆存储器初始化为0”。

换页机制

最简单的伪代码,非常清晰,当程序试图访问线性地址空间上的一个地址位置时,发生以下操作:

if(数据在物理内存中)
{
    虚拟地址转换成物理地址
    读数据
}
else
{
    if(数据在磁盘中)
    {
        if(物理内存还有空闲)
        {
            把数据从磁盘中读到物理内存
            虚拟地址转换成物理地址
            读数据
        }
        else
        {
            把物理内存中某页的数据存入磁盘
            把要读的数据从磁盘读到该页的物理内存中
            虚拟地址转换成物理地址
            读数据
        }
    }
    else
    {
        报错
    }
}

虚拟存储器的三种重要能力

  1. 它将驻村看成是一个存储在磁盘上的地址空间的高速缓存,在主存中只保存活动区域,并根据需要在磁盘和主存之间来回传送数据,通过这种方式,它高效地使用了主存。
  2. 它为每个进程提供了一致的地址空间,简化了存储器管理。
  3. 它保护了每个进程的地址空间不被其他进程破坏。

程序的执行过程

execve函数在当前的进程中加载并运行包含在可执行文件a.out文件中的程序,用a.out程序有效地替代了当前程序。加载并运行a.out需要以下几个步骤:

  1. 删除已存在的用户区域。删除当前进程虚拟地址空间用户部分中已存在的区域结构。
  2. 映射私有区域。为新程序的文本、数据、bss和栈区创建新的区域结构。所有这些新的区域都是私有的、写时拷贝的。文本和数据区被映射为a.out文件中的文本和数据区。bss区域是请求二进制零的,映射到匿名文件,其大小包含在a.out中。栈和堆区也是请求二进制零的。初始长度为0。初始化的效果如下图所示

  3. 映射共享内存。如果a.out程序与共享对象(或目标)链接,比如标准C库libc.so,那么这些对象都是动态链接到这个程序的,然后再映射到用户虚拟地址空间中的共享内存区域内。

  4. 设置程序计数器(PC)。execve做的最后一件事情就是设置当前进程上下文中的程序计数器,使之指向文本区域的入口点。

下一次调度这个进程时,它将从这个入口点开始执行。Linux将根据需要换入代码和数据页面。

多线程

一个进程空间里同时运行多个线程的程序。每个线程有自己的线程上下文,其中包括

  1. 唯一的线程ID,TID;
  2. 栈指针
  3. 程序计数器
  4. 通用目的寄存器
  5. 条件码

所有运行在一个进程内的线程共享该进程的整个虚拟地址空间,包括:

  1. 代码
  2. 数据
  3. 共享库
  4. 打开的文件

【注意】:上文的表述有一点是不确定的,这就是线程栈:这些栈被保存在虚拟地址空间的栈区域中,并且通常是被相应的线程独立地访问。这里我们说通常而不是总是,是因为一个线程栈不对其他线程设防,我们可以通过一个指向其他线程栈的指针(比如一个全局的指针变量)来读写其他线程栈的任何部分。

线程与进程的不同

  1. 线程上下文比进程上下文小很多
  2. 线程没有严格的父子层次关系,意味一个线程可以杀死任何对等线程。
  3. 每个对等线程都能读写相同的共享数据。

关于他们的更多的比较请见多进程与多线程的深度比较

Show Comments