Linux进程深度解析(2):fork/exec写时拷贝性能优化与exit资源回收机制(进程创建和销毁)
文章目录
-
- 0.简介
- 1.进程创建和销毁方式
-
- 1.1 进程创建
- 1.2 进程终止
- 1.3 进程回收
- 2.进程创建原理+源码解读
-
- 2.1 fork
- 2.2 exec
- 3.进程终止原理+源码解读
-
- 3.1 exit
- 4.进程回收原理+源码解读
-
- 4.1 wait
- 5.总结
0.简介
在第一篇文章中我们描述了进程的静态结构,本文将分析进程的动态结构,主要从原理角度去说明每个函数的核心思想以及对于我们的借鉴意义,同时也会附带部分的源码解读(基于Linux 5.10)。
1.进程创建和销毁方式
在linux中,进程创建和终止的方式有很多,本节进行各个函数使用的介绍,下面原理和源码解读会着重分析两种创建方式(fork/exec)以及退出方式(exit)和防止僵尸进程的wait。
1.1 进程创建
1)fork:创建一个子进程,父子进程独立运行,共享代码段,数据段通过写时复制优化性能,其初始时相同;这个地方我们可以看到fork一次调用在父进程和子进程都进行了返回,且父进程返回子进程的pid,子进程返回0,原因在于调用过程中会将父进程eax值设置为子进程pid,而子进程初始化时会显示设置eax值为0,而两次返回的原因是子进程会在父进程返回指令处执行,然后两个进程分别取eax返回值,即得到不一样的pid。
pid_t pid = fork();if(pid < 0){ //error}else if (pid == 0) { // 子进程代码} else { // 父进程代码}
2)vfork:创建子进程,从数据上来说和父进程共享空间,修改的数据会影响父进程;从执行顺序来说子进程先执行,执行结束后父进程才执行。而不像fork一样父子进程都有可能先调度。不推荐使用。
pid_t pid = vfork();if(pid < 0){ //error}if (pid == 0) { // 子进程代码(避免修改共享数据) _exit(0); // 必须用 _exit 退出} else { // 父进程恢复执行}
3)exec:exec族函数是替换当前进程的代码和数据,加载新的程序到内存中执行(不创建新的进程),常用的有execl()、execv()等,常常和fork结合使用。其后缀含义以及具体函数如下:
l:参数以列表(list)形式传递
v:参数以向量/数组(vector)形式传递
p:在PATH环境变量中搜索可执行文件
e:可以传递自定义环境变量数组
4)clone:能够选择性的继承父进程的资源(通过参数 flags 指定父子进程共享的资源(如 CLONE_VM 共享内存空间,CLONE_FILES 共享文件描述符)),可以用于创建进程或者线程(因为linux线程是通过进程承载,所以只要其他部分共享,新分配一块栈空间就可以创建线程)。
#include int child_func(void *arg) { printf(\"Child thread\\n\"); return 0;}char stack[4096]; // 子进程堆栈clone(child_func, stack + 4096, CLONE_VM | CLONE_FS, NULL);
1.2 进程终止
1)exit:终止当前进程,释放资源,返回退出状态给父进程。
int main() { printf(\"Hello\\n\"); exit(0); // 正常退出,会刷新缓冲区 // _exit(0); // 直接退出,不会打印 \"Hello\"(如果未刷新)}
1.3 进程回收
1)wait:父进程等待子进程终止,并获取其退出状态,防止僵尸进程占用资源(如果父进程先结束,子进程会被init接管,正常结束,如果子进程先结束,父进程没用wait,其就会成为僵尸进程占用系统资源),wait函数还有其变形,也就是waitpid函数,指定等待的子进程id。
int main() { pid_t pid = fork(); if (pid == 0) { // 子进程 printf(\"Child process (PID=%d)\\n\", getpid()); exit(42); // 子进程退出,返回 42 } else { // 父进程 int status; pid_t child_pid = wait(&status); // 等待子进程结束 if (WIFEXITED(status)) { printf(\"Child %d exited with status %d\\n\", child_pid, WEXITSTATUS(status)); } else if (WIFSIGNALED(status)) { printf(\"Child %d killed by signal %d\\n\", child_pid, WTERMSIG(status)); } } return 0;}
2.进程创建原理+源码解读
2.1 fork
对于fork原理我们可以这么理解,首先其是一个系统调用,那么使用它就需要通过软中断进入内核态,然后通过查找系统调用表选择系统调用,进入执行;通过系统调用实际执行fork函数(SYSCALL_DEFINE0(fork)),其内部会调用kernel_clone,kernel_clone的流程如下图,其核心在于复制进程的函数copy_process。
copy_process函数很长,我们以其中核心的复制部分作为示例:
/* Perform scheduler related setup. Assign this task to a CPU. */retval = sched_fork(clone_flags, p);if (retval) goto bad_fork_cleanup_policy;retval = perf_event_init_task(p);if (retval) goto bad_fork_cleanup_policy;retval = audit_alloc(p);if (retval) goto bad_fork_cleanup_perf;/* copy all the process information */shm_init_task(p);retval = security_task_alloc(p, clone_flags);if (retval) goto bad_fork_cleanup_audit;retval = copy_semundo(clone_flags, p);if (retval) goto bad_fork_cleanup_security;retval = copy_files(clone_flags, p);if (retval) goto bad_fork_cleanup_semundo;retval = copy_fs(clone_flags, p);if (retval) goto bad_fork_cleanup_files;retval = copy_sighand(clone_flags, p);if (retval) goto bad_fork_cleanup_fs;retval = copy_signal(clone_flags, p);if (retval) goto bad_fork_cleanup_sighand;retval = copy_mm(clone_flags, p);if (retval) goto bad_fork_cleanup_signal;retval = copy_namespaces(clone_flags, p);if (retval) goto bad_fork_cleanup_mm;retval = copy_io(clone_flags, p);if (retval) goto bad_fork_cleanup_namespaces;retval = copy_thread(clone_flags, args->stack, args->stack_size, p, args->tls);if (retval) goto bad_fork_cleanup_io;
总结一下,fork中做的主要就是复制task_struct,复制页表,会采用COW(写时复制)机制,仅仅复制页表项,初始的内存是共享的,然后是复制文件描述符,信号处理等相关资源。从这个设计很容易想到,在我们自己设计程序时,也可以复制元数据代替全量复制,将复制工作延迟到写时复制以提升性能。
2.2 exec
exec系列最终都会调用到系统调用bprm_execve(处理原进程中的残留状态+加载新进程),我们先来看看整体的函数,其中核心的是exec_binprm的调用:
/* * sys_execve()执行一个新程序 * 该函数处理程序执行的前期准备工作,包括文件打开、权限检查和环境设置 */static int bprm_execve(struct linux_binprm *bprm,int fd, struct filename *filename, int flags){ struct file *file; // 要执行的文件 struct files_struct *displaced; // 用于存储被替换的文件结构 int retval; // 返回值 /* * 取消当前任务中所有io_uring相关的异步操作 * 确保执行新程序前没有未完成的异步IO */ io_uring_task_cancel(); retval = unshare_files(&displaced); if (retval) return retval; retval = prepare_bprm_creds(bprm); if (retval) goto out_files; check_unsafe_exec(bprm); /* 标记当前进程正在执行execve操作 */ current->in_execve = 1; /* * 打开要执行的文件 * 处理O_PATH、O_CLOEXEC等标志,并进行权限检查 */ file = do_open_execat(fd, filename, flags); retval = PTR_ERR(file); if (IS_ERR(file)) goto out_unmark; sched_exec(); /* 将打开的文件关联到二进制参数结构 */ bprm->file = file; /* * 记录由于O_CLOEXEC标志导致的路径不可访问性 * 如果文件描述符设置了O_CLOEXEC,执行后该路径将不可访问 */ if (bprm->fdpath && close_on_exec(fd, rcu_dereference_raw(current->files->fdt))) bprm->interp_flags |= BINPRM_FLAGS_PATH_INACCESSIBLE; retval = security_bprm_creds_for_exec(bprm); if (retval) goto out; /* * 真正执行二进制程序的加载 * 解析文件格式(如ELF),设置程序入口点,准备执行环境 */ retval = exec_binprm(bprm); if (retval < 0) goto out; /* execve成功,清理并更新进程状态 */ current->fs->in_exec = 0; current->in_execve = 0; rseq_execve(current); acct_update_integrals(current); task_numa_free(current, false); if (displaced) put_files_struct(displaced); return retval; out: /* * 如果已经过了\"无法返回点\",确保代码不会返回到用户空间 * 如果没有待处理的致命信号,则强制发送SIGSEGV终止进程 */ if (bprm->point_of_no_return && !fatal_signal_pending(current)) force_sigsegv(SIGSEGV);out_unmark: current->fs->in_exec = 0; current->in_execve = 0;out_files: if (displaced) reset_files_struct(displaced); return retval;}
exec_binprm的代码分析如下,其核心函数在于搜索执行器:
/* * 执行二进制程序加载 * 该函数负责查找合适的二进制格式处理程序并执行程序加载 */static int exec_binprm(struct linux_binprm *bprm){... /* * 搜索并调用合适的二进制格式处理程序 * 例如ELF、脚本解释器等 */ ret = search_binary_handler(bprm); ...}
接下来来看search_binary_handler的实现。
/* * 遍历二进制格式处理程序列表,直到找到能识别该映像的处理程序 * 该函数负责寻找并调用合适的加载器来处理目标可执行文件 */static int search_binary_handler(struct linux_binprm *bprm){ bool need_retry = IS_ENABLED(CONFIG_MODULES); // 是否允许重试加载模块 struct linux_binfmt *fmt;// 二进制格式处理程序 int retval; // 返回值 /* * 准备二进制参数结构 * 包括读取文件头部、计算校验和等操作 */ retval = prepare_binprm(bprm); if (retval < 0) return retval; /* * 安全检查 */ retval = security_bprm_check(bprm); if (retval) return retval; /* 初始化返回值为\"未找到文件\" */ retval = -ENOENT;retry: /* * 遍历已注册的二进制格式处理程序列表 * 常见的处理程序包括ELF、脚本解释器等 */ read_lock(&binfmt_lock); list_for_each_entry(fmt, &formats, lh) { /* 尝试获取处理程序模块引用,防止模块在使用过程中被卸载 */ if (!try_module_get(fmt->module)) continue; read_unlock(&binfmt_lock); // 释放读锁,允许并发操作 /* * 调用处理程序的加载函数 * 例如elf_format的load_binary函数 */ retval = fmt->load_binary(bprm); read_lock(&binfmt_lock); // 重新获取读锁 /* 减少模块引用计数 */ put_binfmt(fmt); /* * 如果已达到\"无法返回点\"或处理成功 * 例如已成功加载二进制文件,则返回结果 */ if (bprm->point_of_no_return || (retval != -ENOEXEC)) { read_unlock(&binfmt_lock); return retval; } } read_unlock(&binfmt_lock); // 释放读锁 /* * 如果允许重试且未找到匹配的处理程序 * 尝试动态加载可能的二进制格式处理模块 */ if (need_retry) { /* 检查文件头部是否为可打印字符(可能是脚本文件) */ if (printable(bprm->buf[0]) && printable(bprm->buf[1]) && printable(bprm->buf[2]) && printable(bprm->buf[3])) return retval; // 是文本文件,不再尝试加载模块 /* * 尝试加载可能的二进制格式处理模块 */ if (request_module(\"binfmt-%04x\", *(ushort *)(bprm->buf + 2)) < 0) return retval; // 加载失败,返回错误 need_retry = false; // 已尝试加载模块,不再重试 goto retry; // 重新尝试查找处理程序 } return retval; // 返回最终结果(通常是-ENOEXEC或-ENOENT)}
每种文件都有自己的解析方式,比如elf的加载就是解析文件,设置内存布局等,这个和elf的格式有关(后面编译相关的文字会详细elf文件剖析结构)。
接下来我们来看看从exec的设计我们可以借鉴的地方,首先是单一职责,其只做程序替换;接下来是接口设计灵活性,底层都用同一个函数实现,但上层可以提供多种函数;再有就是插件化的架构,可以适配多种可执行文件格式,也符合开闭原则。
3.进程终止原理+源码解读
3.1 exit
exit的核心代码是do_exit函数,我们看起主要逻辑:
void __noreturn do_exit(long code) { struct task_struct *tsk = current; // 1. 设置进程状态为 EXITING(防止重复退出) exit_signals(tsk); /* sets PF_EXITING */ //2.设置退出码 tsk->exit_code = code; // 3. 释放资源 exit_mm(); // 释放内存管理资源 exit_sem(tsk); // 释放 System V 信号量 exit_files(tsk); // 关闭打开的文件 exit_fs(tsk); // 释放文件系统资源 exit_thread(tsk); // 清理线程信息 exit_itimers(tsk); // 关闭定时器 perf_event_exit_task(tsk); // 性能监控相关 // 4. 通知父进程,处理子进程关系 exit_notify(tsk, group_dead); // 5. 调度其他进程(永不返回) do_task_dead();}
我们重点来看其中和父进程的交互,也就是exit_notify(tsk, group_dead);函数。其主要做的事情如下:
1)将子进程进行托管给新的父进程。
2)设置进程状态为EXIT_ZOMBIE(等待父进程回收)或 EXIT_DEAD(立即释放)。
3)通知父进程,唤醒可能阻塞在wait()的父进程。
/* * Send signals to all our closest relatives so that they know * to properly mourn us.. */static void exit_notify(struct task_struct *tsk, int group_dead){ bool autoreap; struct task_struct *p, *n; LIST_HEAD(dead); write_lock_irq(&tasklist_lock); forget_original_parent(tsk, &dead); if (group_dead) kill_orphaned_pgrp(tsk->group_leader, NULL); tsk->exit_state = EXIT_ZOMBIE; if (unlikely(tsk->ptrace)) { int sig = thread_group_leader(tsk) && thread_group_empty(tsk) && !ptrace_reparented(tsk) ? tsk->exit_signal : SIGCHLD; autoreap = do_notify_parent(tsk, sig); } else if (thread_group_leader(tsk)) { autoreap = thread_group_empty(tsk) && do_notify_parent(tsk, tsk->exit_signal); } else { autoreap = true; } if (autoreap) { tsk->exit_state = EXIT_DEAD; list_add(&tsk->ptrace_entry, &dead); } /* mt-exec, de_thread() is waiting for group leader */ if (unlikely(tsk->signal->notify_count < 0)) wake_up_process(tsk->signal->group_exit_task); write_unlock_irq(&tasklist_lock); list_for_each_entry_safe(p, n, &dead, ptrace_entry) { list_del_init(&p->ptrace_entry); release_task(p); }}
总结一下,对于exit的设计我们可以借鉴的有:
1)责任分离托管:可以将子进程进行托管,避免失控。
2)状态驱动:通过状态转移管理生命周期。
4.进程回收原理+源码解读
4.1 wait
wait其实和exit是存在关联的,上面说退出时会给父进程发送信号唤醒父进程的wait,就是此处要介绍的函数,wait_consider_task()支持等待多种目标(僵尸进程,停止进程等),其真正要有的操作就是释放子进程的task_struct和收集信息,我们来看其释放函数:
void release_task(struct task_struct *p){ struct task_struct *leader; struct pid *thread_pid; int zap_leader;repeat: /* don\'t need to get the RCU readlock here - the process is dead and * can\'t be modifying its own credentials. But shut RCU-lockdep up */ rcu_read_lock(); atomic_dec(&__task_cred(p)->user->processes); rcu_read_unlock(); cgroup_release(p); write_lock_irq(&tasklist_lock); ptrace_release_task(p); thread_pid = get_pid(p->thread_pid); __exit_signal(p); /* * If we are the last non-leader member of the thread * group, and the leader is zombie, then notify the * group leader\'s parent process. (if it wants notification.) */ zap_leader = 0; leader = p->group_leader; if (leader != p && thread_group_empty(leader) && leader->exit_state == EXIT_ZOMBIE) { /* * If we were the last child thread and the leader has * exited already, and the leader\'s parent ignores SIGCHLD, * then we are the one who should release the leader. */ zap_leader = do_notify_parent(leader, leader->exit_signal); if (zap_leader) leader->exit_state = EXIT_DEAD; } write_unlock_irq(&tasklist_lock); seccomp_filter_release(p); proc_flush_pid(thread_pid); put_pid(thread_pid); release_thread(p); put_task_struct_rcu_user(p); p = leader; if (unlikely(zap_leader)) goto repeat;}
5.总结
本篇介绍了进程的创建和退出以及回收原理,对主要过程和设计思路进行描述,而不聚焦具体细节。