我们说过一个进程就是一个PCB,即一个task_struck结构体,那么创建进程也就是创建一个task_struck结构体。在Linux中我们常用fork()函数来创建进程。
fork()主要用于以复制正在调用进程的方式去创建一个新的进程,新进程叫做子进程,原来的进程叫做父进程。
#include <unistd.h> pid_t fork ()fork创建进程:通过复制调用父进程(PCB)创建一个新的子进程(PID不同)。
由 fork()创建的新进程被称为子进程。fork()函数调用一次,但返回两次。两次返回的区别是:子进程的返回值是 0,而父进程的返回值则是新建子进程的进程 ID。通过fork()的返回值来区别父子进程,如果出错返回-1。子进程与父进程代码和数据指向同一块内存区域,并且子进程复制了程序计数器、上下文数据。当前的子进程不是从代码的起始位置开始运行,而是从子进程创建成功(fork())的下一步指令开始运行的。调用fork()前的代码只有父进程执行,fork()成功返回后的代码,父子进程都会执行。 int main(){ //pid_t fork(void); //通过复制调用进程,创建一个新的子进程 pid_t pid=fork(); //因为父子进程返回值不同,虽然代码相同,父进程因为返回值会进入不同的判断执行体 if(pid==0){ printf("i am child! pid:%d\n",getpid()); } else if(pid>0){ printf("i am parent! pid:%d\n",getpid()); }else{ //<0,出错 //perror:打印上一个系统调用的错误信息 perror("fork error"); return -1; } printf("pid:%d\n",getpid()); return 0; } 创建子进程的意义:分摊任务处理压力,让子进程完成其他任务。返回值类型为pid_t
子进程在执行fork()时返回 0父进程在执行fork()时,fork()创建子进程,返回子进程的PID父进程在用fork()创建子进程失败时返回 -1因为fork运行有多种结果,所以往往fork之后要根据fork的返回值进行分流,用 if写多个分支。
#include<stdio.h> #include<unistd.h> int main(){ pid_t pid = fork(); if(pid == -1){ perror("fork error"); } else if(pid == 0){ printf("子进程执行\n子进程pid:%d\n", getpid()); } else{ printf("父进程执行\n父进程pid:%d\n", getpid()); } return 0; }编译执行如下, 当父进程用fork() 创建子进程成功后,返回了其子进程的pid, 然后继续执行,直到执行打印语句后子进程才执行,如下: 但并不是父进程创建了子进程,父进程就一定会先执行完,才执行子进程,也可能是父进程执行到一半,甚至刚调用fork()创建完子进程后,就立即转而执行子进程。这取决于CPU的调度。
#include<stdio.h> #include<unistd.h> int main(){ pid_t pid = fork(); if(pid == -1){ perror("fork error"); } else if(pid == 0){ printf("子进程执行\n子进程pid:%d\n", getpid()); } else{ printf("父进程开始执行\n"); sleep(5); printf("父进程执行\n父进程pid:%d\n", getpid()); printf("父进程运行结束\n"); } return 0; }可以看到,父进程执行到一半开始执行子进程了。由于父进程中sleep()函数 致使父进程进入睡眠状态(当sleep()执行完,就会中断睡眠, 进入就绪状态, 等待分配时间片),子进程当被创建后, 一直处于就绪状态,等待分配时间片;当父进程睡眠时,子进程拿到了时间片,子进程执行;当子进程执行完,父进程拿到时间片,父进程继续运行。
所以就有, fork创建子进程之前, 父进程独立运行, 创建子进程之后, 谁先运行取决于调度器的调度
进程调用fork, 内核会做出以下操作:
分配新的内存块和内核数据结构给子进程将父进程部分数据结构内容拷贝至子进程 ( 此时已经创建了子进程的PCB)添加子进程到系统进程列表当中 ( 即添加子进程PCB)fork返回,调度器开始调度Liunx的fork()创建子进程采取分时拷贝的策略:父进程创建子进程时,只创建了子进程的task_struct(PCB),并没有直接给子进程开辟内存来拷贝数据,而是跟父进程一样映射到同一位置,但是如果父进程或子进程有一方想要修改内存中的数据时,那么对于改变的这块内存,需要重新给子进程开辟内存,并且更新子进程页表信息。这样做提高创建子进程的性能,并且能节省内存。
父子进程共享代码与数据: 父子进程代码共享,数据独有: 分页式内存管理可以将一段程序加载到不连续的物理空间上,但是从虚拟地址空间来看依旧是连续的,用以解决内存使用率低的问题。mm_struct结构体也叫内存描述符,其中记录虚拟内存各个段的起始地址,结束地址。通过这种方式描述了进程的虚拟地址空间。每一个进程都会有唯一的mm_struct结构体, mm_struct记录在task_struct中。
页表:页表中存储的是虚拟地址和物理地址的映射关系,即页号到物理块的地址映射。通过虚拟地址得到页号与页内地址(或者叫页内偏移),在页表中通过页号找到物理块号。物理地址 = 物理块号 x 页面大小 + 页内偏移。
#include<stdio.h> #include<stdlib.h> #include<unistd.h> int main(){ pid_t pid = fork(); int data = 0; if(pid == -1){ perror("fork erro"); } else if(pid == 0){ printf("子进程执行\n"); data = 10; printf("data = %d\n", data); printf("data地址: %p\n", &data); } else{ sleep(2); printf("父进程执行\n"); printf("data = %d\n", data); printf("data地址: %p\n", &data); } return 0; }先让父进程睡上2秒,这时会执行子进程,子进程修改了data的值为10,但子进程结束后,父进程继续执行打印出的data还是0。两个进程所打印的值不同,但父子进程中data的地址都是一样的。
父子进程数据不同,则数据一定存储在不同的物理地址上。打印的变量地址却依旧相同,这是因为取地址符&得到的并不是物理地址(在所有有关地址的操作中, 我们只能接触到虚拟地址),而是虚拟地址。虚拟地址虽然相同, 但是父子进程有着不同的mm_struct,即有着不同的页表,父子进程的data相同的(虚拟)地址通过不同的页表映射到不同的物理地址上。
fork()函数当然不是为了创建子进程而创建子进程, 创建子进程目的有两种:
一个父进程希望复制自己,使父子进程同时执行不同的代码段。 例如: 父进程等待客户端请求,生成子进程来处理请求。一个进程要执行一个不同的程序。1. 进程四要素
有一段程序供其执行(不一定是一个进程所专有的)。有自己的专用系统堆栈空间。有进程控制块PCB(task_struct)。有独立的存储空间。缺少独立的存储空间的被称为线程,如果完全没有用户空间的称为内核线程,共享用户空间的称为用户线程。
2. fork()
fork()使用写时拷贝实现。写时拷贝是一种可以推迟甚至避免拷贝数据的技术。内核此时并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间。只在需要写入的时候才会复制地址空间,从而使各个进程拥有各自的地址空间。
3. vfork()
vfork也是创建一个子进程,但是子进程共享父进程的空间。在vfork创建子进程之后,父进程阻塞,直到子进程执行了exec()或者exit()。vfork创建出来的不是真正意义上的进程,而是一个线程,因为它缺少第四个要素:独立的内存资源。
vfork创建的子进程要先于父进程执行,子进程执行时,父进程处于挂起状态,子进程执行完,唤醒父进程。除非子进程exit或者execve才会唤起父进程。
4. clone() clone是Linux为创建线程设计的(虽然也可以用clone创建进程)。所以可以说clone是fork的升级版本,不仅可以创建进程或者线程,还可以指定创建新的命名空间(namespace)、有选择的继承父进程的内存、甚至可以将创建出来的进程变成父进程的兄弟进程等等。
int clone(int (*fn)(void *), void *child_stack, int flags, void *arg);clone和fork最大不同在于clone不再复制父进程的栈空间,而是自己创建一个新的。 (void *child_stack)也就是第二个参数,需要分配栈指针的空间大小,所以它不再是继承或者复制,而是全新的创造。