大家好,我是蟹老板~

今天我们继续聊 Linux 进程~,面试别人和被别人面试,进程的创建与退出永远是绕不开的坎。说真的,很多人写了无数次 fork,连它到底怎么在内核里创建出进程的都没掰扯明白,更别说 vfork、clone 这些弯弯绕绕了。今天趁着周六有空,带大家从头到尾捋一遍——进程是怎么“生”出来、又是怎么“死”干净的。

文章有些长~万字预警

一、进程创建

先问大家一个问题:进程创建的时候,最怕什么?

——最怕慢啊!

你想啊,如果一个父进程占了几百兆内存,fork一个子进程就得把这几百兆物理内存全部拷贝一份,要是真这么干,你开个服务占了几个 G 内存,fork 一下不得等个好几秒?

所以Linux内核的大佬们用了三招来解决这个问题:

第一个就是写时复制,也就是大家常说的 COW。子进程刚创建出来的时候,和父进程共享同一个物理页框,内核只给子进程拷贝一份页表,内存里的实际数据半毛钱都不动。只有父进程或者子进程任何一方想改这个物理页的时候,才会触发缺页异常,内核这时候才会真的给要修改的进程复制一个新的页框,标记成可写,让它自己改自己的。原来的页面还是写保护的,要是再有进程想改,内核先看这个页框的引用计数,只有一个进程在用的话,直接放开写权限就行,不用再复制了。

这个引用计数,是靠页描述符 struct page 里的_refcount 原子操作实现的,不会有并发问题。就这么个 “不到万不得已绝不复制” 的设计,直接把进程创建的性能拉满了。

第二个是轻量级进程。它允许父子进程共享内核里的很多数据结构,比如页表、打开的文件句柄、信号处理函数这些,不用每个进程都单独拷贝一份,我们平时用的线程,本质上就是靠这个机制实现的。

第三个就是 vfork () 系统调用。它创建子进程的时候,会直接把父进程阻塞住,直到子进程退出或者执行 execve 加载新程序,父进程才会恢复运行。这样就从根源上保证了父进程不会和子进程抢着访问共享的地址空间,连页表都不用单独拷贝,速度更快。

1.1 clone()、fork()和vfork()

聊到进程创建,就绕不开用户态最常用的三个系统调用:fork、vfork 和 clone。很多人只知道 fork,对另外两个要么一知半解,要么根本没用过。其实你去翻内核源码就会发现,这三个东西,最后全是殊途同归,走到同一个函数里去了。

(本文基于 Linux 内核5.6.4 版本)

Linux 提供了三个创建进程的系统调用:fork、vfork、clone。它们的核心区别其实就是共享哪些资源:

/*
 * fork系统调用的内核实现
 * SYSCALL_DEFINE0 表示这是无参数的系统调用宏
 */
SYSCALL_DEFINE0(fork)
{
// 只有带内存管理单元(MMU)的架构才支持传统fork
// 无MMU的单片机场景无法使用写时复制,不支持该调用
#ifdef CONFIG_MMU
// 组装进程创建的核心参数结构体
struct kernel_clone_args args = {
// 子进程退出时,会向父进程发送SIGCHLD信号,这是fork的默认行为
		.exit_signal = SIGCHLD,
	};


// 所有创建逻辑最终都落到_do_fork函数中
return _do_fork(&args);
#else
// 无MMU模式直接返回不支持的错误码
return -EINVAL;
#endif
}


/*
 * vfork系统调用的内核实现
 * 同样是无参数的系统调用
 */
SYSCALL_DEFINE0(vfork)
{
// 组装vfork专属的创建参数
struct kernel_clone_args args = {
// CLONE_VM:父子进程共享整个内存地址空间
// CLONE_VFORK:阻塞父进程,直到子进程释放内存空间
		.flags		= CLONE_VFORK | CLONE_VM,
// 同样在子进程退出时发送SIGCHLD信号
		.exit_signal	= SIGCHLD,
	};


// 同样调用_do_fork核心函数
return _do_fork(&args);
}


/*
 * clone系统调用的内核实现
 * SYSCALL_DEFINE5 表示这是带5个参数的系统调用宏
 * 参数分别为:clone标志位、子进程栈地址、父进程tid指针、子进程tid指针、TLS地址
 */
SYSCALL_DEFINE5(clone, unsigned long, clone_flags, unsigned long, newsp,
		 int __user *, parent_tidptr,
		 int __user *, child_tidptr,
		 unsigned long, tls)
#endif
{
// 从用户态传入的参数里拆解、组装创建参数
struct kernel_clone_args args = {
// 提取除退出信号外的所有标志位
		.flags		= (clone_flags & ~CSIGNAL),
		.pidfd		= parent_tidptr,
		.child_tid	= child_tidptr,
		.parent_tid	= parent_tidptr,
// 低8位是子进程退出时发送的信号编号
		.exit_signal	= (clone_flags & CSIGNAL),
// 子进程的用户栈地址
		.stack		= newsp,
// 线程局部存储TLS的地址
		.tls		= tls,
	};


// 校验用户态传入的参数是否合法
if (!legacy_clone_args_valid(&args))
return -EINVAL;


// 最终还是调用_do_fork核心函数
return _do_fork(&args);
}

很多人看到这里就会问了,既然三个调用最后都走_do_fork,那 Linux 干嘛还要搞三个接口?直接一个 clone 不就完事了?

你想想,fork 是 1970 年代就定下来的 Unix 标准接口,总不能说删就删吧?兼容这件事,在 Linux 内核里,比天还大。

你再去翻 Linux 3.0 版本 X86 架构的源码,会发现它的实现更简单,三个调用最后都走 do_fork:

// 3.0版本fork系统调用
int sys_fork(struct pt_regs *regs)
{
// 仅设置退出信号SIGCHLD,其余标志位全为0
return do_fork(SIGCHLD, regs->sp, regs, 0, NULL, NULL);
}


/*
 * 注释是内核源码里原生带的,我给大家翻译一下:
 * 这个函数看起来很简单,表面上完全可以在用户态实现
 * 但其实不行,原因非常隐蔽:寄存器压力
 * 用户态的vfork()不能有栈帧,要是直接调用clone()系统调用
 * 根本没有足够的寄存器来存放所有需要的参数
 */
int sys_vfork(struct pt_regs *regs)
{
// 相比fork,多了CLONE_VFORK和CLONE_VM标志位
return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs->sp, regs, 0,
		       NULL, NULL);
}


// 3.0版本clone系统调用
long
sys_clone(unsigned long clone_flags, unsigned long newsp,
	  void __user *parent_tid, void __user *child_tid, struct pt_regs *regs)
{
// 如果用户没指定子进程栈,就用父进程的栈地址
if (!newsp)
		newsp = regs->sp;
// 所有参数透传给do_fork
return do_fork(clone_flags, newsp, regs, 0, parent_tid, child_tid);
}

对比两个版本的源码就能看出来,3.0 的 do_fork 带了 struct pt_regs 入参,这个结构体是 CPU 架构相关的,存放着进程陷入内核时的寄存器上下文,所以老版本的实现和 X86 架构强绑定。5.6.4 版本把这个解耦了,改成了通用的 kernel_clone_args 结构体,适配性强了太多。内核的迭代,其实就是在这些细节里一点点优化的。

这两个版本的核心逻辑其实没差,最终都是靠 copy_process () 完成进程的复制,只有 TLS(线程局部存储)的传递方式有点区别。老版本通过 pt_regs 传入,新版本直接用一个 unsigned long 参数传递,而且只有设置了 CLONE_SETTLS 标志位的时候,才会去复制 TLS。

1.2 clone_flag

说到这里,就得好好聊聊 clone_flags 标志位了。整个进程创建的核心,全在这一串标志位里。你想让父子进程共享什么、不共享什么,全靠它来控制。

/*
 * 进程克隆标志位定义
 * 低8位是子进程退出时发送给父进程的信号编号,其余位是功能标志
 */
#define CSIGNAL		0x000000ff	/* 退出时发送的信号掩码 */
#define CLONE_VM	0x00000100	/* 父子进程共享内存描述符和所有页表,vfork和线程创建必带 */
#define CLONE_FS	0x00000200	/* 共享根目录和当前工作目录 */
#define CLONE_FILES	0x00000400	/* 共享打开的文件描述符表,多线程场景常用 */
#define CLONE_SIGHAND	0x00000800	/* 共享信号处理函数表、阻塞/挂起信号集,必须和CLONE_VM同时使用 */


#define CLONE_PIDFD	0x00001000	/* 给父进程返回一个pidfd,用于进程管理,避免PID复用问题 */
#define CLONE_PTRACE	0x00002000	/* 父进程被跟踪的话,子进程也会被跟踪,gdb调试子进程靠这个 */
#define CLONE_VFORK	0x00004000	/* 阻塞父进程直到子进程释放内存空间,vfork专用 */
#define CLONE_PARENT	0x00008000	/* 子进程和父进程有同一个父进程,也就是创建出来的是兄弟不是父子 */
#define CLONE_THREAD	0x00010000	/* 父子进程加入同一个线程组,pthread_create底层必带这个标志,实现POSIX线程标准 */
#define CLONE_NEWNS	0x00020000	/* 给子进程创建新的挂载命名空间,容器技术的核心基础之一 */
#define CLONE_SYSVSEM	0x00040000	/* 共享System V信号量的SEM_UNDO操作 */
#define CLONE_SETTLS	0x00080000	/* 给子进程创建新的TLS线程局部存储 */
#define CLONE_PARENT_SETTID	0x00100000	/* 把子进程的PID写入父进程用户态的parent_tidptr地址 */
#define CLONE_CHILD_CLEARTID	0x00200000	/* 子进程退出时,清空其用户态child_tidptr地址的内容,用于用户态线程同步 */
#define CLONE_DETACHED		0x00400000	/* 废弃未使用,内核直接忽略 */
#define CLONE_UNTRACED		0x00800000	/* 禁止跟踪进程强制给本次克隆加上CLONE_PTRACE,内核线程创建常用 */
#define CLONE_CHILD_SETTID	0x01000000	/* 把子进程的PID写入子进程用户态的child_tidptr地址 */


/* 下面这一组是命名空间相关的标志位,全是容器技术的核心 */
#define CLONE_NEWCGROUP		0x02000000	/* 新的cgroup命名空间,用于隔离资源限制 */
#define CLONE_NEWUTS		0x04000000	/* 新的UTS命名空间,隔离主机名和域名 */
#define CLONE_NEWIPC		0x08000000	/* 新的IPC命名空间,隔离信号量、共享内存、消息队列 */
#define CLONE_NEWUSER		0x10000000	/* 新的用户命名空间,隔离用户ID和组ID,容器里的root就是靠这个实现的 */
#define CLONE_NEWPID		0x20000000	/* 新的PID命名空间,隔离进程PID,容器里PID从1开始就是靠它 */
#define CLONE_NEWNET		0x40000000	/* 新的网络命名空间,隔离网卡、IP、端口、路由表,容器网络的核心 */
#define CLONE_IO		0x80000000	/* 共享IO上下文 */

fork只用了SIGCHLD,所以开销最大;vfork加了CLONE_VFORK|CLONE_VM,共享内存但父进程等;clone可以灵活组合,比如你想创建一个新线程,通常就是CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND。

1.3 do_fork()实现

我们再回到核心的_do_fork 函数。不管是 fork、vfork 还是 clone,最后都要走到这里,它就是进程创建的总入口。

// 进程创建的核心参数结构体,把所有创建相关的参数都封装在这里
struct kernel_clone_args {
	u64 flags;                // clone标志位
int __user *pidfd;        // pidfd返回地址
int __user *child_tid;    // 子进程tid存储地址
int __user *parent_tid;   // 父进程tid存储地址
int exit_signal;          // 子进程退出时发送的信号
unsigned long stack;      // 子进程栈地址
unsigned long stack_size; // 子进程栈大小
unsigned long tls;        // 线程局部存储地址
	pid_t *set_tid;           // 手动指定的PID数组
	size_t set_tid_size;      // PID数组长度
};


/*
 * 这是fork流程的核心主函数
 * 它完成进程的复制,成功后就唤醒子进程,必要时会等待子进程释放内存
 * 入参args的合法性由调用方提前校验
 */
long _do_fork(struct kernel_clone_args *args)
{
	u64 clone_flags = args->flags;
struct completion vfork;  // vfork场景的完成量,用于父子进程同步
struct pid *pid;
struct task_struct *p;    // 子进程的进程描述符,也就是task_struct
int trace = 0;
long nr;


/*
	 * 判断是否要给ptrace跟踪器上报事件
	 * 如果是内核线程创建,或者设置了CLONE_UNTRACED,就不上报
	 * 否则根据fork类型上报对应的事件
	 */
if (!(clone_flags & CLONE_UNTRACED)) {
if (clone_flags & CLONE_VFORK)
			trace = PTRACE_EVENT_VFORK;
else if (args->exit_signal != SIGCHLD)
			trace = PTRACE_EVENT_CLONE;
else
			trace = PTRACE_EVENT_FORK;


if (likely(!ptrace_event_enabled(current, trace)))
			trace = 0;
	}


/* 最核心的一步:复制进程描述符,完成子进程的所有初始化工作 */
	p = copy_process(NULL, trace, NUMA_NO_NODE, args);
// 给系统增加随机熵,用于安全相关的场景
	add_latent_entropy();


// 如果copy_process出错,直接返回错误码
if (IS_ERR(p))
return PTR_ERR(p);


// 跟踪点,用于调度器统计进程fork事件
	trace_sched_process_fork(current, p);


/* 给子进程分配PID,获取PID结构体 */
	pid = get_task_pid(p, PIDTYPE_PID);
	nr = pid_vnr(pid);


// 如果设置了CLONE_PARENT_SETTID,把子进程PID写入父进程的用户态地址
if (clone_flags & CLONE_PARENT_SETTID)
		put_user(nr, args->parent_tid);


// 如果是vfork场景,初始化完成量,用于阻塞父进程
if (clone_flags & CLONE_VFORK) {
		p->vfork_done = &vfork;
		init_completion(&vfork);
		get_task_struct(p);
	}


/* 唤醒子进程,把它加入CPU的运行队列,等待调度执行 */
	wake_up_new_task(p);


// fork完成,子进程已经启动,给ptrace上报事件
if (unlikely(trace))
		ptrace_event_pid(trace, pid);


// 如果是vfork场景,把父进程加入等待队列,直到子进程释放内存空间
// 这就是vfork能保证子进程先于父进程执行的核心原因
if (clone_flags & CLONE_VFORK) {
if (!wait_for_vfork_done(p, &vfork))
			ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid);
	}


	put_pid(pid);
return nr;
}

这个函数的逻辑其实很清晰,核心就三件事:第一,调用 copy_process 完成子进程的所有初始化和资源复制;第二,唤醒子进程,把它丢进调度队列;第三,如果是 vfork,就把父进程阻塞住,等子进程完事再唤醒。

而整个进程创建里最复杂、最核心的,就是 copy_process 函数。它负责创建子进程的进程描述符,还有进程运行需要的所有数据结构。

copy_process 的

目录

一、进程创建

1.1 clone()、fork()和vfork()

1.2 clone_flag

1.3 do_fork()实现

二、内核线程

三、进程退出


步而已:

  1. 先对 clone_flags 做严格的参数校验,哪些标志不能同时用,哪些必须同时用,全在这里卡着,不合法直接返回错误;
  2. 调用 dup_task_struct 给子进程分配进程描述符,同时初始化 thread_info,这是进程的 “身份证”;
  3. 初始化进程描述符里的各种链表、信号、时间统计等基础成员;
  4. 给子进程设置调度策略、优先级、调度类,决定它之后怎么被 CPU 调度;
  5. 调用一系列 copy_xxx 函数,根据标志位决定是复制还是共享父进程的文件、信号、内存、命名空间等资源;
  6. 调用 copy_thread_tls,初始化和 CPU 架构相关的线程结构体,这里就是 fork 两个返回值的根源;
  7. 设置好父子进程的亲缘关系,更新统计计数,最后返回子进程的进程描述符。
/*
 * 创建一个新进程,作为父进程的副本,但不会启动它
 * 会根据clone标志位,复制寄存器和进程环境的所有相关部分
 * 进程的启动由调用方完成
 */
static __latent_entropy struct task_struct *copy_process(
struct pid *pid,
int trace,
int node,
struct kernel_clone_args *args)
{
int pidfd = -1, retval;
struct task_struct *p; // 即将创建的子进程的进程描述符
struct multiprocess_signals delayed;
struct file *pidfile = NULL;
	u64 clone_flags = args->flags;
struct nsproxy *nsp = current->nsproxy;


// 第一部分:标志位合法性校验,不合法直接返回错误,这里坑特别多
// CLONE_NEWNS和CLONE_FS不能同时设置,不能和不同命名空间的进程共享根目录
if ((clone_flags & (CLONE_NEWNS|CLONE_FS)) == (CLONE_NEWNS|CLONE_FS))
return ERR_PTR(-EINVAL);


// CLONE_NEWUSER和CLONE_FS也不能同时设置
if ((clone_flags & (CLONE_NEWUSER|CLONE_FS)) == (CLONE_NEWUSER|CLONE_FS))
return ERR_PTR(-EINVAL);


// 要加入同一个线程组,必须共享信号处理函数,这是POSIX标准要求的
if ((clone_flags & CLONE_THREAD) && !(clone_flags & CLONE_SIGHAND))
return ERR_PTR(-EINVAL);


// 要共享信号处理函数,必须共享内存地址空间,不然信号处理会出问题
if ((clone_flags & CLONE_SIGHAND) && !(clone_flags & CLONE_VM))
return ERR_PTR(-EINVAL);


// init进程不能创建兄弟进程,不然会导致僵尸进程无法回收
if ((clone_flags & CLONE_PARENT) &&
				current->signal->flags & SIGNAL_UNKILLABLE)
return ERR_PTR(-EINVAL);


// 要创建新的PID/用户命名空间,就不能加入父进程的线程组
if (clone_flags & CLONE_THREAD) {
if ((clone_flags & (CLONE_NEWUSER | CLONE_NEWPID)) ||
		    (task_active_pid_ns(current) != nsp->pid_ns_for_children))
return ERR_PTR(-EINVAL);
	}


// 省略其余的标志位校验,核心逻辑都是一样的,不合法就直接返回


// 第二部分:信号相关的预处理,确保fork期间的信号处理正常
	sigemptyset(&delayed.signal);
	INIT_HLIST_NODE(&delayed.node);


	spin_lock_irq(&current->sighand->siglock);
if (!(clone_flags & CLONE_THREAD))
		hlist_add_head(&delayed.node, &current->signal->multiprocess);
	recalc_sigpending();
	spin_unlock_irq(&current->sighand->siglock);
	retval = -ERESTARTNOINTR;
// 如果当前有挂起的信号,先退出处理信号,再继续fork
if (signal_pending(current))
goto fork_out;


// 第三部分:创建子进程的进程描述符task_struct
	retval = -ENOMEM;
// 复制当前进程的进程描述符,给子进程分配内核栈、thread_info等核心结构
	p = dup_task_struct(current, node);
if (!p) // 内存不足,分配失败
goto fork_out;


// 初始化子进程的tid相关字段
	p->set_child_tid = (clone_flags & CLONE_CHILD_SETTID) ? args->child_tid : NULL;
	p->clear_child_tid = (clone_flags & CLONE_CHILD_CLEARTID) ? args->child_tid : NULL;


// 省略一堆调试、统计相关的初始化,不影响核心流程


// 校验当前用户的进程数是否超过限制,防止fork炸弹
	retval = -EAGAIN;
if (atomic_read(&p->real_cred->user->processes) >=
			task_rlimit(p, RLIMIT_NPROC)) {
if (p->real_cred->user != INIT_USER &&
		    !capable(CAP_SYS_RESOURCE) && !capable(CAP_SYS_ADMIN))
goto bad_fork_free;
	}


// 复制进程的凭证信息,包括UID、GID、权限等
	retval = copy_creds(p, clone_flags);
if (retval < 0)
goto bad_fork_free;


// 校验系统总线程数是否超过上限
	retval = -EAGAIN;
if (nr_threads >= max_threads)
goto bad_fork_cleanup_count;


// 第四部分:初始化进程描述符的所有基础成员
	delayacct_tsk_init(p);
// 设置进程标志位,PF_FORKNOEXEC表示刚创建,还没执行execve
	p->flags &= ~(PF_SUPERPRIV | PF_WQ_WORKER | PF_IDLE);
	p->flags |= PF_FORKNOEXEC;
// 初始化子进程、兄弟进程链表
	INIT_LIST_HEAD(&p->children);
	INIT_LIST_HEAD(&p->sibling);
	rcu_copy_process(p);
	p->vfork_done = NULL;
// 初始化自旋锁
	spin_lock_init(&p->alloc_lock);


// 初始化挂起信号集
	init_sigpending(&p->pending);


// 初始化CPU时间统计变量
	p->utime = p->stime = p->gtime = 0;
// 省略一堆统计、定时器相关的初始化


// 第五部分:调度相关的初始化,给子进程分配CPU,设置调度策略
	retval = sched_fork(clone_flags, p);
if (retval)
goto bad_fork_cleanup_policy;


// 省略perf、审计相关的初始化


// 第六部分:核心的资源复制/共享,全是copy_xxx系列函数
	shm_init_task(p);
	retval = security_task_alloc(p, clone_flags);
if (retval)
goto bad_fork_cleanup_audit;
// 复制System V信号量相关信息
	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;
// 复制IO上下文
	retval = copy_io(clone_flags, p);
if (retval)
goto bad_fork_cleanup_namespaces;
// 第七部分:架构相关的线程初始化,fork两个返回值的根源就在这里
	retval = copy_thread_tls(clone_flags, args->stack, args->stack_size, p,
				 args->tls);
if (retval)
goto bad_fork_cleanup_io;


// 第八部分:给子进程分配PID
if (pid != &init_struct_pid) {
		pid = alloc_pid(p->nsproxy->pid_ns_for_children, args->set_tid,
				args->set_tid_size);
if (IS_ERR(pid)) {
			retval = PTR_ERR(pid);
goto bad_fork_cleanup_thread;
		}
	}


// 省略pidfd相关的处理


// 第九部分:设置进程的线程组、亲缘关系
	p->pid = pid_nr(pid);
// 如果设置了CLONE_THREAD,就加入父进程的线程组,tgid和父进程一致
if (clone_flags & CLONE_THREAD) {
		p->exit_signal = -1;
		p->group_leader = current->group_leader;
		p->tgid = current->tgid;
	} else {
// 否则自己就是线程组组长,tgid等于自己的PID
if (clone_flags & CLONE_PARENT)
			p->exit_signal = current->group_leader->exit_signal;
else
			p->exit_signal = args->exit_signal;
		p->group_leader = p;
		p->tgid = p->pid;
	}


// 省略一堆线程组相关的初始化


	cgroup_threadgroup_change_begin(current);
	retval = cgroup_can_fork(p);
if (retval)
goto bad_fork_cgroup_threadgroup_change_end;


// 记录进程启动时间
	p->start_time = ktime_get_ns();
	p->start_boottime = ktime_get_boottime_ns();


// 第十部分:把进程加入系统的进程链表,让它对整个系统可见
	write_lock_irq(&tasklist_lock);


// 设置进程的父进程
if (clone_flags & (CLONE_PARENT|CLONE_THREAD)) {
		p->real_parent = current->real_parent;
		p->parent_exec_id = current->parent_exec_id;
	} else {
		p->real_parent = current;
		p->parent_exec_id = current->self_exec_id;
	}


	klp_copy_process(p);


	spin_lock(&current->sighand->siglock);


	copy_seccomp(p);
	rseq_fork(p, clone_flags);


// 省略一堆异常校验,比如PID命名空间是否正在销毁、是否有致命信号


// 把进程加入对应的PID链表、线程组链表
	init_task_pid_links(p);
if (likely(p->pid)) {
		ptrace_init_task(p, (clone_flags & CLONE_PTRACE) || trace);


		init_task_pid(p, PIDTYPE_PID, pid);
if (thread_group_leader(p)) {
// 如果是进程组长,加入全局进程链表
			init_task_pid(p, PIDTYPE_TGID, pid);
			init_task_pid(p, PIDTYPE_PGID, task_pgrp(current));
			init_task_pid(p, PIDTYPE_SID, task_session(current));


if (is_child_reaper(pid)) {
				ns_of_pid(pid)->child_reaper = p;
				p->signal->flags |= SIGNAL_UNKILLABLE;
			}
			p->signal->shared_pending.signal = delayed.signal;
			p->signal->tty = tty_kref_get(current->signal->tty);
// 加入父进程的子进程链表
			list_add_tail(&p->sibling, &p->real_parent->children);
			list_add_tail_rcu(&p->tasks, &init_task.tasks);
// 挂载PID到对应的哈希表
			attach_pid(p, PIDTYPE_TGID);
			attach_pid(p, PIDTYPE_PGID);
			attach_pid(p, PIDTYPE_SID);
			__this_cpu_inc(process_counts);
		} else {
// 如果是线程,加入父进程的线程组
			current->signal->nr_threads++;
			atomic_inc(&current->signal->live);
			refcount_inc(&current->signal->sigcnt);
			task_join_group_stop(p);
			list_add_tail_rcu(&p->thread_group,
					  &p->group_leader->thread_group);
			list_add_tail_rcu(&p->thread_node,
					  &p->signal->thread_head);
		}
		attach_pid(p, PIDTYPE_PID);
		nr_threads++;
	}


// 系统总fork计数加1
	total_forks++;
	hlist_del_init(&delayed.node);
	spin_unlock(&current->sighand->siglock);
	syscall_tracepoint_update(p);
	write_unlock_irq(&tasklist_lock);


// 省略收尾工作,cgroup、perf等事件通知
	proc_fork_connector(p);
	cgroup_post_fork(p);
	cgroup_threadgroup_change_end(current);
	perf_event_fork(p);


	trace_task_newtask(p, clone_flags);
	uprobe_copy_process(p, clone_flags);


// 所有工作完成,返回子进程的进程描述符
return p;


// 下面全是错误处理的跳转分支,创建失败的时候,把已经分配的资源全部释放干净
// 内核里的错误处理全是这种goto模式,非常清晰,不会有内存泄漏
bad_fork_cancel_cgroup:
	spin_unlock(&current->sighand->siglock);
	write_unlock_irq(&tasklist_lock);
	cgroup_cancel_fork(p);
bad_fork_cgroup_threadgroup_change_end:
	cgroup_threadgroup_change_end(current);
bad_fork_put_pidfd:
if (clone_flags & CLONE_PIDFD) {
		fput(pidfile);
		put_unused_fd(pidfd);
	}
bad_fork_free_pid:
if (pid != &init_struct_pid)
		free_pid(pid);
bad_fork_cleanup_thread:
	exit_thread(p);
bad_fork_cleanup_io:
if (p->io_context)
		exit_io_context(p);
bad_fork_cleanup_namespaces:
	exit_task_namespaces(p);
bad_fork_cleanup_mm:
if (p->mm) {
		mm_clear_owner(p->mm, p);
		mmput(p->mm);
	}
bad_fork_cleanup_signal:
if (!(clone_flags & CLONE_THREAD))
		free_signal_struct(p->signal);
bad_fork_cleanup_sighand:
	__cleanup_sighand(p->sighand);
bad_fork_cleanup_fs:
	exit_fs(p);
bad_fork_cleanup_files:
	exit_files(p);
bad_fork_cleanup_semundo:
	exit_sem(p);
bad_fork_cleanup_security:
	security_task_free(p);
bad_fork_cleanup_audit:
	audit_free(p);
bad_fork_cleanup_perf:
	perf_event_free_task(p);
bad_fork_cleanup_policy:
	lockdep_free_task(p);
#ifdef CONFIG_NUMA
	mpol_put(p->mempolicy);
bad_fork_cleanup_threadgroup_lock:
#endif
	delayacct_tsk_free(p);
bad_fork_cleanup_count:
	atomic_dec(&p->cred->user->processes);
	exit_creds(p);
bad_fork_free:
	p->state = TASK_DEAD;
	put_task_stack(p);
	delayed_free_task(p);
fork_out:
	spin_lock_irq(&current->sighand->siglock);
	hlist_del_init(&delayed.node);
	spin_unlock_irq(&current->sighand->siglock);
return ERR_PTR(retval);
}

这里面有两个核心的函数,必须重点说一下。第一个就是 copy_mm,它负责子进程内存地址空间的复制,写时复制 COW 的核心逻辑就是在这里触发的。

/*
 * 复制进程的内存地址空间
 * 根据clone标志位,决定是共享还是复制父进程的mm_struct
 */
static int copy_mm(unsigned long clone_flags, struct task_struct *tsk)
{
struct mm_struct *mm, *oldmm;
int retval;


// 初始化缺页统计、上下文切换统计
	tsk->min_flt = tsk->maj_flt = 0;
	tsk->nvcsw = tsk->nivcsw = 0;
#ifdef CONFIG_DETECT_HUNG_TASK
	tsk->last_switch_count = tsk->nvcsw + tsk->nivcsw;
	tsk->last_switch_time = 0;
#endif


// 先把子进程的mm和active_mm置空
	tsk->mm = NULL;
	tsk->active_mm = NULL;


// 获取当前进程的mm_struct
	oldmm = current->mm;
// 如果当前进程的mm为空,说明是内核线程,直接返回
// 内核线程没有自己的用户地址空间,不需要复制mm
if (!oldmm)
return 0;


// 清空vmacache,加速后续的虚拟地址查找
vmacache_flush(tsk);


// 如果设置了CLONE_VM标志,直接共享父进程的内存地址空间
// vfork和线程创建都会走这个分支,不会复制页表,速度非常快
if (clone_flags & CLONE_VM) {
mmget(oldmm); // 增加mm的引用计数
		mm = oldmm;
goto good_mm;
	}


// 没有设置CLONE_VM,就创建新的mm_struct,复制父进程的内存描述符
// 这里只会复制页表,不会复制物理内存,写时复制COW就是在这里实现的
	retval = -ENOMEM;
	mm = dup_mm(tsk, current->mm);
if (!mm)
goto fail_nomem;


// 设置子进程的mm和active_mm
good_mm:
	tsk->mm = mm;
	tsk->active_mm = mm;
return 0;


fail_nomem:
return retval;
}

第二个就是 copy_thread_tls,这是和 CPU 架构强相关的函数,我当年面试被问 “为什么 fork 会有两个返回值”,就是栽在这里。大家看代码里的这一行:childregs->ax = 0;,X86 架构里,系统调用的返回值存在 eax 寄存器里。子进程创建的时候,内核把它的 eax 寄存器置为 0,所以子进程从 fork 返回的时候,拿到的就是 0。而父进程的 eax 寄存器里存的是子进程的 PID,所以返回的就是子进程的 PID。就这么一行代码,就是 fork 两个返回值的根源。

/*
 * X86架构下的线程上下文复制函数
 * 初始化子进程的寄存器上下文,设置TLS
 */
int copy_thread_tls(unsigned long clone_flags, unsigned long sp,
		    unsigned long arg, struct task_struct *p, unsigned long tls)
{
struct inactive_task_frame *frame;
struct fork_frame *fork_frame;
struct pt_regs *childregs; // 子进程的寄存器上下文
int ret = 0;


// 获取子进程内核栈里的寄存器上下文地址
	childregs = task_pt_regs(p);
	fork_frame = container_of(childregs, struct fork_frame, regs);
	frame = &fork_frame->frame;


// 初始化栈帧,设置子进程的入口地址为ret_from_fork
// 子进程被调度后,会从ret_from_fork开始执行
	frame->bp = 0;
	frame->ret_addr = (unsigned long) ret_from_fork;
// 设置子进程的内核栈指针
	p->thread.sp = (unsigned long) fork_frame;
	p->thread.io_bitmap = NULL;
memset(p->thread.ptrace_bps, 0, sizeof(p->thread.ptrace_bps));


#ifdef CONFIG_X86_64
// 保存段寄存器信息
savesegment(gs, p->thread.gsindex);
	p->thread.gsbase = p->thread.gsindex ? 0 : current->thread.gsbase;
savesegment(fs, p->thread.fsindex);
	p->thread.fsbase = p->thread.fsindex ? 0 : current->thread.fsbase;
savesegment(es, p->thread.es);
savesegment(ds, p->thread.ds);
#else
	p->thread.sp0 = (unsigned long) (childregs + 1);
	frame->flags = X86_EFLAGS_FIXED;
#endif


// 如果是创建内核线程,单独初始化
if (unlikely(p->flags & PF_KTHREAD)) {
memset(childregs, 0, sizeof(struct pt_regs));
kthread_frame_init(frame, sp, arg);
return 0;
	}


// 把父进程的寄存器上下文完整复制给子进程
	frame->bx = 0;
	*childregs = *current_pt_regs();
// 重点!把子进程的eax寄存器置为0,这就是fork子进程返回0的根源!
	childregs->ax = 0;
// 如果用户指定了子进程的栈地址,就用用户指定的
if (sp)
		childregs->sp = sp;


#ifdef CONFIG_X86_32
task_user_gs(p) = get_user_gs(current_pt_regs());
#endif


// 如果设置了CLONE_SETTLS,给子进程设置新的线程局部存储
if (clone_flags & CLONE_SETTLS)
		ret = set_new_tls(p, tls);


if (!ret && unlikely(test_tsk_thread_flag(current, TIF_IO_BITMAP)))
io_bitmap_share(p);


return ret;
}

往期文章推荐

图解 TCP/IP协议,看图秒懂

图解 C/C++ 多线程,看图秒懂

爆肝整理!嵌入式开发必知10种调试手段

【图解】SSH(安全外壳协议)工作原理

👉 【就业避坑】C++ 就业前景全解析:为什么劝退声不断,大厂核心岗仍刚需 C++?

👉 【大厂标准】Linux C/C++ 后端开发系统学习路线

👉 【音视频】音视频流媒体高级开发核心学习路径

👉 【Qt进阶】C++ Qt 桌面 & 嵌入式开发一条龙学习攻略

👉 【内核底层】Linux 内核硬核修炼指南

👉 【面试冲刺】C/C++ 高频八股面试题 1000 题(三)

二、内核线程

很多人学 Linux 进程,学半天都没搞懂内核线程和普通进程到底有啥区别,甚至有人觉得 ps 命令里看到的都是普通进程,这就大错特错了。

内核线程只运行在内核态,它的切换完全不用经过用户态和内核态的切换,开销特别小。

创建内核线程两种常见的方法:

  • kernel_thread:直接调_do_fork,但一定会带上CLONE_VM | CLONE_UNTRACED。
  • kthread_create:更高级的接口,创建的线程处于stopped状态,你得手动wake_up_process。

看看kernel_thread的实现,你就明白了:

/*
 * 创建一个内核线程
 * @fn: 内核线程要执行的函数
 * @arg: 传给函数的参数
 * @flags: clone标志位
 */
pid_t kernel_thread(int (*fn)(void *), void *arg, unsigned long flags)
{
// 组装创建参数
struct kernel_clone_args args = {
// 强制设置CLONE_VM,共享父进程的内存空间,不用复制页表
// 强制设置CLONE_UNTRACED,禁止被跟踪
		.flags		= ((flags | CLONE_VM | CLONE_UNTRACED) & ~CSIGNAL),
		.exit_signal	= (flags & CSIGNAL),
// 栈地址指向线程执行函数
		.stack		= (unsigned long)fn,
// 栈大小就是函数的入参
		.stack_size	= (unsigned long)arg,
	};


// 直接调用_do_fork创建
return _do_fork(&args);
}

这里的 CLONE_VM 标志是必须的,因为内核线程根本不会访问用户地址空间,完全没必要复制父进程的页表,直接共享就行,能省不少开销。

那些著名的内核线程——ksoftirqd(软中断)、kswapd(内存回收)——都是这么生出来的。它们的父进程是谁——?就是2号进程kthreadd。

/*
 * 在当前NUMA节点创建一个内核线程
 * @threadfn: 线程执行函数
 * @data: 函数入参
 * @namefmt: 线程名称,支持printf格式
 * @arg...: 名称的可变参数
 * 这个宏创建的线程是STOPPED状态,必须调用wake_up_process才能启动
 */
#define kthread_create(threadfn, data, namefmt, arg...) \
	kthread_create_on_node(threadfn, data, NUMA_NO_NODE, namefmt, ##arg)


struct task_struct *kthread_create_on_node(int (*threadfn)(void *data),
					   void *data, int node,
					   const char namefmt[],
					   ...)
{
struct task_struct *task;
	va_list args;


	va_start(args, namefmt);
// 核心实现都在__kthread_create_on_node里
	task = __kthread_create_on_node(threadfn, data, node, namefmt, args);
	va_end(args);


return task;
}

__kthread_create_on_node 的逻辑也不复杂,它会把创建线程的请求打包成一个结构体,加入到 kthread_create_list 全局链表里,然后唤醒 2 号进程 kthreadd,让它来真正创建内核线程。

顺便说一下 Linux 里的三个祖宗级进程:

第一个就是 0 号进程,也叫 idle 进程。它是所有进程的祖先,是 Linux 系统初始化阶段,从无到有静态创建出来的内核线程,进程描述符是静态分配的,不是靠 fork 创建的。系统启动的时候,它干了两件大事:第一件,创建了 1 号 init 进程,第二件,创建了 2 号 kthreadd 进程。干完这两件事,它就躺平了,CPU 没事干的时候,就会跑它,所以叫 idle 进程。SMP 架构里,每个 CPU 核心都有自己的 idle 进程,全是复制启动核心的 idle 进程来的。

第二个是 1 号进程,init 进程。它是所有用户态进程的祖先,是个用户态进程。0 号进程创建它之后,它会执行 kernel_init 函数,最终调用 execve 加载 /sbin/init 程序,进入用户态。你在 shell 里敲的所有命令,跑的所有服务,往上数祖宗,全是它。

第三个是 2 号进程,kthreadd 进程。它是所有内核线程的父进程,是个内核线程,也是 0 号进程创建的。它的核心逻辑就是死循环遍历 kthread_create_list 链表,只要链表里有创建请求,就调用 create_kthread 创建内核线程,创建完就把请求从链表里删掉。链表空了,它就把自己设置成可中断睡眠状态,等有新请求了再被唤醒。

kthreadd 的核心实现,真的非常简洁:

/*
 * 2号进程的核心执行函数
 * 所有内核线程的父进程,负责处理内核线程创建请求
 */
int kthreadd(void *unused)
{
struct task_struct *tsk = current;


// 设置进程名称为"kthreadd"
	set_task_comm(tsk, "kthreadd");
// 忽略所有信号
	ignore_signals(tsk);
// 设置可以跑在所有CPU核心上
	set_cpus_allowed_ptr(tsk, cpu_all_mask);
	set_mems_allowed(node_states[N_MEMORY]);


	current->flags |= PF_NOFREEZE;
	cgroup_init_kthreadd();


// 死循环,永远不会退出
for (;;) {
// 先把自己设置成可中断睡眠状态
		set_current_state(TASK_INTERRUPTIBLE);
// 如果创建链表为空,就主动让出CPU,进入睡眠
if (list_empty(&kthread_create_list))
			schedule();
// 被唤醒了,说明有创建请求,设置成运行状态
		__set_current_state(TASK_RUNNING);


// 加锁,遍历创建链表
		spin_lock(&kthread_create_lock);
while (!list_empty(&kthread_create_list)) {
struct kthread_create_info *create;


// 从链表里拿到第一个创建请求
			create = list_entry(kthread_create_list.next,
					    struct kthread_create_info, list);
// 把请求从链表里删掉
			list_del_init(&create->list);
			spin_unlock(&kthread_create_lock);


// 调用create_kthread创建内核线程,最终还是调用kernel_thread
			create_kthread(create);


			spin_lock(&kthread_create_lock);
		}
		spin_unlock(&kthread_create_lock);
	}


return 0;
}

看到这里大家明白了把,不管是用户态的进程、线程,还是内核态的内核线程,创建的时候最终都绕不开_do_fork(老版本是 do_fork),资源要不要共享、共享多少,全靠 clone_flag 标志位说了算。

三、进程退出

有生就有死,聊完了进程的创建,我们再聊聊进程的退出。

进程退出,本质上就是通知内核,我要结束了,把我占的资源都收回去。这些资源包括内存、打开的文件、信号量、命名空间等等,少释放一样,就会造成资源泄漏。

exit_group 是终止整个线程组,也就是整个进程,我们平时用的 C 库函数 exit,底层走的就是 exit_group 系统调用。你在 main 函数里调用 exit,整个进程里的所有线程都会被杀死。

exit 是终止当前线程,不会影响同一个进程里的其他线程,我们用的线程库函数 pthread_exit,底层走的就是 exit 系统调用。

我当年就踩过这个坑,多线程程序里,一个线程异常调用了 C 库的 exit,结果整个进程直接挂了,查了半宿才明白,原来 exit 底层是 exit_group,会杀整个进程。

exit_group 的内核实现是 do_group_exit,逻辑很清晰,就是把整个线程组里的所有线程都杀死,最后调用 do_exit 结束自己:

/*
 * 杀死线程组里的所有线程,终止整个进程
 * @exit_code: 进程退出码
 */
void do_group_exit(int exit_code)
{
struct signal_struct *sig = current->signal;


	BUG_ON(exit_code & 0x80);


// 如果已经在执行线程组退出了,就用已经设置好的退出码
if (signal_group_exit(sig))
		exit_code = sig->group_exit_code;
// 如果线程组里不止一个线程,就杀死其他所有线程
else if (!thread_group_empty(current)) {
struct sighand_struct *const sighand = current->sighand;


		spin_lock_irq(&sighand->siglock);
if (signal_group_exit(sig))
// 其他线程已经先一步触发了组退出,用它的退出码
			exit_code = sig->group_exit_code;
else {
// 设置线程组退出标志和退出码
			sig->group_exit_code = exit_code;
			sig->flags = SIGNAL_GROUP_EXIT;
// 杀死线程组里除了当前线程之外的所有线程,给它们发SIGKILL
			zap_other_threads(current);
		}
		spin_unlock_irq(&sighand->siglock);
	}


// 调用do_exit结束当前线程,所有线程最终都会走到这里
	do_exit(exit_code);
// 这里永远不会执行到,因为do_exit不会返回
}

里面的 zap_other_threads 函数,就是遍历整个线程组,给除了当前线程之外的所有线程发 SIGKILL 信号,收到信号的线程都会执行 do_exit 退出。

所有进程、线程的退出,最终都会走到 do_exit 函数里。它就是进程生命周期的终点,负责把进程占的所有资源,干干净净地还给系统。

/*
 * 进程退出的核心函数,所有进程/线程退出最终都会走到这里
 * 不会返回,进程会直接被销毁
 * @code: 进程退出码
 */
void __noreturn do_exit(long code)
{
struct task_struct *tsk = current;
int group_dead;


profile_task_exit(tsk);
kcov_task_exit(tsk);


WARN_ON(blk_needs_flush_plug(tsk));


// 安全校验:中断上下文里不能调用do_exit,否则直接panic
if (unlikely(in_interrupt()))
panic("Aiee, killing interrupt handler!");
// 不能杀死0号idle进程,否则直接panic
if (unlikely(!tsk->pid))
panic("Attempted to kill the idle task!");


// 把地址空间限制重置为用户态,防止意外写入内核地址
set_fs(USER_DS);


ptrace_event(PTRACE_EVENT_EXIT, code);


validate_creds_for_do_exit(tsk);


// 处理递归退出的情况,防止内核崩溃
if (unlikely(tsk->flags & PF_EXITING)) {
pr_alert("Fixing recursive fault but reboot is needed!\n");
futex_exit_recursive(tsk);
set_current_state(TASK_UNINTERRUPTIBLE);
schedule();
	}


// 设置进程标志位为PF_EXITING,标记进程正在退出
exit_signals(tsk);


// 处理原子上下文的异常情况
if (unlikely(in_atomic())) {
pr_info("note: %s[%d] exited with preempt_count %d\n",
			current->comm, task_pid_nr(current),
preempt_count());
preempt_count_set(PREEMPT_ENABLED);
	}


// 同步内存RSS统计信息
if (tsk->mm)
sync_mm_rss(tsk->mm);
acct_update_integrals(tsk);
// 判断是不是线程组里最后一个活着的线程
	group_dead = atomic_dec_and_test(&tsk->signal->live);
if (group_dead) {
// 如果是init进程退出了,直接panic,系统不能没有init
if (unlikely(is_global_init(tsk)))
panic("Attempted to kill init! exitcode=0x%08x\n",
				tsk->signal->group_exit_code ?: (int)code);


#ifdef CONFIG_POSIX_TIMERS
// 取消进程的POSIX定时器
hrtimer_cancel(&tsk->signal->real_timer);
exit_itimers(tsk->signal);
#endif
if (tsk->mm)
setmax_mm_hiwater_rss(&tsk->signal->maxrss, tsk->mm);
	}
acct_collect(code, group_dead);
if (group_dead)
tty_audit_exit();
audit_free(tsk);


// 保存进程退出码
	tsk->exit_code = code;
taskstats_exit(tsk, group_dead);


// 释放进程的内存地址空间,把mm_struct还给系统
exit_mm();


if (group_dead)
acct_process();
trace_sched_process_exit(tsk);


// 释放所有进程相关的资源,一个都不能漏
exit_sem(tsk);        // 释放System V信号量
exit_shm(tsk);        // 释放共享内存
exit_files(tsk);      // 关闭所有打开的文件,释放文件描述符表
exit_fs(tsk);         // 释放文件系统相关资源
if (group_dead)
disassociate_ctty(1); // 断开和控制终端的关联
exit_task_namespaces(tsk); // 释放命名空间
exit_task_work(tsk);  // 执行收尾的任务工作
exit_thread(tsk);     // 释放架构相关的线程资源
exit_umh(tsk);


// 释放perf事件相关资源
perf_event_exit_task(tsk);


sched_autogroup_exit_task(tsk);
cgroup_exit(tsk);


flush_ptrace_hw_breakpoint(tsk);


// 通知父进程,子进程要退出了
exit_tasks_rcu_start();
exit_notify(tsk, group_dead);
proc_exit_connector(tsk);
mpol_put_task_policy(tsk);
#ifdef CONFIG_FUTEX
if (unlikely(current->pi_state_cache))
kfree(current->pi_state_cache);
#endif


// 校验是否还持有锁,持有锁退出会造成死锁
debug_check_no_locks_held();


if (tsk->io_context)
exit_io_context(tsk);


if (tsk->splice_pipe)
free_pipe_info(tsk->splice_pipe);


if (tsk->task_frag.page)
put_page(tsk->task_frag.page);


validate_creds_for_do_exit(tsk);


check_stack_usage();
preempt_disable();
if (tsk->nr_dirtied)
		__this_cpu_add(dirty_throttle_leaks, tsk->nr_dirtied);
exit_rcu();
exit_tasks_rcu_finish();


lockdep_free_task(tsk);
// 最后一步,把进程标记为死亡,等待父进程回收进程描述符
do_task_dead();
}

do_exit 函数会把进程占的所有资源都释放干净,唯独留下进程描述符 task_struct。

为什么?

因为父进程需要通过它拿到子进程的退出码,知道它是正常退出还是异常退出。只有当父进程调用 wait 或者 waitpid,给子进程 “收尸” 之后,这个进程描述符才会被释放。

如果子进程退出了,父进程没给它收尸,这个进程描述符就会一直留在系统里,变成我们常说的僵尸进程。僵尸进程不占内存、不占 CPU,但是会占用 PID,系统的 PID 是有限的,僵尸进程多了,PID 用完了,就再也创建不了新进程了。我当年踩过的那个坑,就是父进程没处理 SIGCHLD 信号,子进程退出了没人收尸,堆了上千个僵尸进程,最后被运维找上门。

唠到这里,Linux 进程从生到死的整条链路,大家应该都串起来了。

不管是用户态的 fork、vfork、clone,还是内核态的 kernel_thread、kthread_create,最终都会走到_do_fork 函数里,靠 copy_process 完成进程的所有初始化和资源复制,资源是复制还是共享,全靠 clone_flag 标志位说了算。

不管是终止整个进程的 exit_group,还是终止单个线程的 exit,最终都会走到 do_exit 函数里,把进程占的所有资源释放干净,等待父进程回收最后的进程描述符。

1 号 init 进程是所有用户态进程的祖先,2 号 kthreadd 进程是所有内核线程的祖先,而它俩,都是 0 号 idle 进程创建的

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐