【C++高并发服务器WebServer】-1:Linux中父子进程fork创建及关系、GDB多进程调试
本文目录
- 一、进程创建
- 二、GDB多进程调试
一、进程创建
在Linux中输入man 2 fork
可以查看man文档中的fork的相关函数信息。
fork的作用就是创建一个子进程。
通过fork我们可以知道,创建子进程的时候,复制父进程的信息。
我们看看翻译的man文档信息:
- 子进程和父进程在独立的内存空间中运行。在调用 fork() 时,两个内存空间的内容是相同的。其中一个进程进行的写操作、文件映射以及取消映射不会影响另一个进程。
首先我们来说说,在 fork() 调用时,子进程和父进程的内存空间内容是相同的,这是因为 fork() 的实现机制是通过“写时复制”(Copy-On-Write,简称 COW)来实现的。
- 内存空间的复制
首先,fork() 的目标是创建一个与父进程几乎完全相同的子进程。为此,它需要复制父进程的内存空间,包括代码段、数据段、堆和栈等。在调用 fork() 的瞬间,子进程的内存空间内容与父进程完全一致。当然,上面两句话指的是虚拟地址空间。
在 fork() 后,父子进程会有相同的虚拟地址空间映射,父进程的数据和堆栈的内容在物理内存中不会立即复制。实际上,操作系统使用了页表(paging)来标记这些内存页面为只读,且标记为共享的。
- 写时复制(Copy-On-Write)机制
虽然内存空间的内容在 fork() 时是相同的,但实际的物理内存并没有完全复制。操作系统采用了“写时复制”技术来优化资源使用。
在 fork() 调用后,父进程和子进程共享相同的物理内存页面。只有当父进程或子进程中的任意一个对内存进行写操作时,操作系统才会真正复制该页面的内容到一个新的物理页面,并将修改后的页面分配给进行写操作的进程。如果父进程和子进程都没有对内存进行写操作,它们将继续共享相同的物理页面。
当父进程或子进程尝试修改某一共享的内存页时,操作系统才会进行实际的物理内存复制。这意味着只有在进程写入某个页面时,内核才会为该页面分配新的物理内存,并将原页面的内容复制到新的物理内存位置,从而保证父子进程的内存内容互不影响。
所以由于 fork() 的目标是创建一个与父进程完全相同的子进程,因此在调用 fork() 的瞬间,子进程的内存空间内容必须与父进程一致。这是通过逻辑上的“复制”实现的(虚拟地址空间),而物理内存的实际复制则通过写时复制机制延迟到真正需要修改内存时才进行。
- “运行在不同的内存空间”(The child process and the parent process run in separate memory spaces)
这句话的核心在于“内存空间”(memory space)的概念。内存空间指的是进程的虚拟内存空间(virtual memory space),而不是物理内存(physical memory)。每个进程都有自己的虚拟内存空间,这是操作系统为进程分配的逻辑地址空间。虚拟内存空间是独立的,每个进程都无法直接访问其他进程的虚拟内存空间。这种隔离是现代操作系统实现进程隔离和保护的基础。
当 fork() 创建子进程时,子进程会获得一个全新的虚拟内存空间。虽然这个虚拟内存空间的内容在创建时与父进程的虚拟内存空间内容相同,但它们是完全独立的。子进程无法直接访问父进程的虚拟内存空间,反之亦然。
虽然父进程和子进程运行在不同的虚拟内存空间中,但它们的内存内容在 fork() 调用时是相同的。一是因为逻辑上的复制:在 fork() 调用时,子进程的虚拟内存空间被初始化为与父进程的虚拟内存空间内容一致。这意味着子进程的代码段、数据段、堆和栈等在逻辑上与父进程相同。
二是因为写时复制(Copy-On-Write):操作系统通过写时复制技术优化了内存的使用。虽然虚拟内存空间的内容在逻辑上是相同的,但物理内存页面并不会立即复制。只有当父进程或子进程对某个页面进行写操作时,操作系统才会真正复制该页面的内容到新的物理内存中。所以只需要明确:在 fork() 调用时,子进程的虚拟内存空间的内容与父进程相同,但这种相同是逻辑上的,物理内存的复制是通过写时复制机制实现的。
在linux中新建c++文件,运行下面代码,gcc fork.c -o fork
和./fork
。
/*
pid_t fork(void):
作用:创建子进程。
返回值;
fork会返回两次:
一次在父进程中,一次在子进程中。
在父进程中,返回子进程的ID;
在子进程中,返回0.
如何区分父进程和子进程,就可以通过fork返回值。
如果在父进程中返回-1,表示创建子进程失败,并且设置对应的error
(当系统的进程数达到了系统上限或者内存不足的时候,就会失败)
*/
# include<sys/types.h>
# include<unistd.h>
# include<stdio.h>
int main(){
pid_t pid = fork(); //这个pid是int类型的
//因为fork会返回两个,分别对应两个进程
//判断是父进程还是子进程
if (pid>0){
printf("pid: %d\n", pid);
printf("我是父进程,pid : %d,ppid: %d\n",getpid(),getppid());
}else if(pid==0){
printf("我是子进程,pid : %d,ppid: %d\n",getpid(),getppid());
}
for(int i=0;i<5;i++){
printf("i : %d, pid : %d \n",i,getpid());
sleep(1);
}
return 0;
}
输出如下:
当前的pid是fork程序33228,ppid是32535(这个是我的连接linux终端bash的进程id,然后子进程id是33228,能够对应上。)
pid: 33228
我是父进程,pid : 33227,ppid: 32535
i : 0, pid : 33227
我是子进程,pid : 33228,ppid: 33227
i : 0, pid : 33228
i : 1, pid : 33227
i : 1, pid : 33228
i : 2, pid : 33227
i : 2, pid : 33228
i : 3, pid : 33227
i : 3, pid : 33228
i : 4, pid : 33228
i : 4, pid : 33227
从上面也可以看出,是父进程和子进程交替在运行for循环,从这一点也可以看出cpu是时间片分给父子进程交替运行。
并且父子进程都在运行for,也可以看出父子进程共享了代码。
下图是父子进程虚拟地址空间示意图,fork以后,子进程的用户区数据和父进程一样(因为是拷贝过来的),内核区也会拷贝过来,但是内核区的pid不一样。
父进程执行 pid_t pid = fork() 得到的返回值,是在栈空间,得到的返回值是子进程的pid(比如33228),但是子进程clone得到的虚拟地址空间中的栈空间所得到的pid是0,但是linux内核中子进程的pid是33228(注意返回的id和自身的pid)。
虚拟地址空间(Virtual Address Space)是现代计算机操作系统中一个非常重要的概念,它是操作系统为每个进程分配的逻辑地址空间。简单来说,虚拟地址空间是一个进程能够访问的内存地址范围,但这些地址并不是直接映射到物理内存(实际的硬件内存)的地址,而是通过操作系统和硬件的联合管理,映射到物理内存或其他存储资源的逻辑地址。
虚拟地址空间通常由以下几部分组成:
代码段(Code Segment):存放程序的可执行代码。
数据段(Data Segment):存放程序的全局变量和静态变量。
堆(Heap):用于动态分配内存(如通过 malloc() 或 new 分配的内存)。
栈(Stack):用于存储函数调用时的局部变量、函数参数和返回地址等。
共享库(Shared Libraries):存放程序运行时加载的动态链接库(如 .so 文件)。
未映射区域(Unmapped Regions):未分配给任何用途的内存区域,通常用于未来扩展。
虚拟地址空间是现代操作系统实现内存管理、进程隔离和内存保护的核心机制。它使得每个进程都能在一个独立的、安全的环境中运行,同时允许操作系统灵活地管理物理内存资源。
简单来说,虚拟地址空间是一个进程的“私有内存世界”,它通过操作系统和硬件的支持,将逻辑地址与物理地址分离,从而实现高效的内存管理和进程隔离。
所以总结一下就是:内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间,只要需要写入的时候才会复制地址空间,从而使得各个进程拥有各自的地址空间,也就是说,资源的复制是在写入的时候才会进行(读时共享,写时拷贝)。fork产生的子进程与父进程相同的文件描述符指向相同的文件夹,且用户区的数据相同。
二、GDB多进程调试
使用GDB进行调试的时候, 默认只能跟踪一个进程,可以在fork函数调用之前,通过指令设置GDB调试工具跟踪父进程或者跟踪子进程,默认跟踪父进程。
set follow-fork-mode [parent(默认)|child]
命令是设置调试父进程或者子进程。
设置调试模式:`set detach-on-fork [on|off],默认为on,表示调试当前进程的时候其他进程继续运行,如果为off,则调试当前进程的时候,其他进程被GDB挂起。
查看调试的进程:info inferiors
, 切换当前调试的进程:inferior id
,使进程脱离GDB调试:detech inferiors id
。
# include<sys/types.h>
# include<unistd.h>
# include<stdio.h>
int main(){
printf("begin\n");
//判断是父进程还是子进程
if (fork()>0){
printf("我是父进程,pid : %d,ppid: %d\n",getpid(),getppid());
int i;
for(i=0;i<10;i++){
printf("i=%d\n",i);
sleep(1);
}
}else{
printf("我是子进程,pid : %d,ppid: %d\n",getpid(),getppid());
int j;
for(j=0;j<10;j++){
printf("i=%d\n",j);
sleep(1);
}
}
return 0;
}
输入命令进行编译调试:gcc hello.c -o hello -g
,然后通过gdb hello
进行调试。
在12行、19行插入断点。
然后输入r、n(next)、n,就会发现下面的调试过程,也就是gdb默认调试父进程,然后子进程的代码会走掉。
在gdb环境下,通过命令show follow-fork-mode
可以查看现有的fork模式,我们通过set follow-fork-mode child
来跟踪子进程。
这个时候再继续run,就会发现断点在19行停住了。
通过info inferiors可以查看调试进程,并且inferior 2切换调试的进程。