在操作系统中进程是资源隔离的一种抽象机制。进程出现的一个重要目的是为了保证操作系统的稳定性。在操作系统中,任务和任务之间的资源(例如内存)是隔离的,任务之间的行为不会相互影响,当单一任务崩溃时,并不会影响其他任务的继续进行。
每个进程都有创建,运行和结束。本文主要聚焦于进程结束阶段的操作系统行为,分析操作系统这样一个关键的软件系统是如何进行资源的回收和释放的,帮助我们更为深入地理解底层行为,让我们自己的编码更加鲁邦和合理。
rCore是由清华大学推出的一个简洁的操作系统实现。清华大学团队在rCore中开发了操作系统常用的:虚拟内存,进程管理,文件系统等组件,是低成本学习操作系统,进行底层开发实践的优秀平台。
本文就基于rCore中ch4分支的代码来解析操作系统是如何对进程进行回收的。
在内核看来,进程是什么?
从数据结构的角度来看就是一个结构体。在rCore的ch4分支中,定义了这样的结构体来描述一个进程:
pub struct TaskControlBlockInner {
/// The physical page number of the frame where the trap context is placed
pub trap_cx_ppn: PhysPageNum,
/// Application data can only appear in areas
/// where the application address space is lower than base_size
pub base_size: usize,
/// Save task context
pub task_cx: TaskContext,
/// Maintain the execution status of the current process
pub task_status: TaskStatus,
/// Application address space
pub memory_set: MemorySet,
/// Parent process of the current process.
/// Weak will not affect the reference count of the parent
pub parent: Option<Weak<TaskControlBlock>>,
/// A vector containing TCBs of all child processes of the current process
pub children: Vec<Arc<TaskControlBlock>>,
/// It is set when active exit or execution error occurs
pub exit_code: i32,
/// Heap bottom
pub heap_bottom: usize,
/// Program break
pub program_brk: usize,
/// System calls
pub system_calls: [u32; MAX_SYSCALL_NUM],
/// execution time
pub time: usize,
}
从注释中我们可以大致将一个进程持有的成员变量分为以下几类:
运行时状态一方面包含了当前进程的执行状态,另一方面则记录了进程在执行中的CPU快照,用来进行进程在内核态和用户态之间的转换,以及内核中不同进程之间的切换。(需要注意的是此时还没有多线程的概念,每个进程只有一个线程)。
pub task_cx: TaskContext,
pub task_status: TaskStatus,
pub trap_cx_ppn: PhysPageNum,
pub exit_code: i32,
pub system_calls: [u32; MAX_SYSCALL_NUM],
pub time: usize,
pub memory_set: MemorySet,
pub parent: Option<Weak<TaskControlBlock>>,
pub children: Vec<Arc<TaskControlBlock>>,
进程的回收涉及到子进程和父进程之间的配合。大致过程如下:
#[no_mangle]
#[link_section = ".text.entry"]
pub extern "C" fn _start(argc: usize, argv: usize) -> ! {
clear_bss();
unsafe {
HEAP.lock()
.init(HEAP_SPACE.as_ptr() as usize, USER_HEAP_SIZE);
}
let mut v: Vec<&'static str> = Vec::new();
for i in 0..argc {
let str_start =
unsafe { ((argv + i * core::mem::size_of::<usize>()) as *const usize).read_volatile() };
let len = (0usize..)
.find(|i| unsafe { ((str_start + *i) as *const u8).read_volatile() == 0 })
.unwrap();
v.push(
core::str::from_utf8(unsafe {
core::slice::from_raw_parts(str_start as *const u8, len)
})
.unwrap(),
);
}
exit(main(argc, v.as_slice()));
}
用户编写的main函数会被包装在用户库的_start
函数中(定义如上),main函数结束后,会调用exit()系统调用。
用户进程进入内核态后,会执行exit_current_and_run_next
内核函数,内核函数中会进行如下操作:
这里有两个值得注意的问题:
Zombie 状态是进程生命周期的最后状态,之所以设置这个状态,主要目的在于:等待父进程回收子进程,并获取子进程的退出状态。
在Linux系统中,父进程会通过waitpid()
的系统调用主动地回收子进程。rCore中也实现了这个系统调用,那么具体是如何实现的呢?
pub fn sys_waitpid(pid: isize, exit_code_ptr: *mut i32) -> isize {
let task = current_task().unwrap();
// 1. 检查是否有符合条件的子进程
let mut inner = task.inner_exclusive_access();
if !inner.children.iter()
.any(|p| pid == -1 || pid as usize == p.getpid()) {
return -1; // 没有这样的子进程
}
// 2. 查找已经变成僵尸进程的子进程
let pair = inner.children.iter().enumerate()
.find(|(_, p)| {
// 检查是否是僵尸进程且匹配 pid
p.inner_exclusive_access().is_zombie() &&
(pid == -1 || pid as usize == p.getpid())
});
if let Some((idx, _)) = pair {
// 3. 找到僵尸子进程,开始回收
let child = inner.children.remove(idx);
// 确保这是最后一个引用
assert_eq!(Arc::strong_count(&child), 1);
// 4. 获取退出码
let found_pid = child.getpid();
let exit_code = child.inner_exclusive_access().exit_code;
// 5. 将退出码写入用户空间
*translated_refmut(inner.memory_set.token(), exit_code_ptr) = exit_code;
found_pid as isize
} else {
-2 // 子进程还在运行
}
}
首先,sys_waitpid
会根据我们输入的pid,判断对应的子进程是否处于 Zombie 状态。如果为-1,则检查所有的子进程。
当检查到子进程设为 Zombie 状态时,会从child进程容器中移除子进程。此时需要注意:
子进程的TaskControlBlockInner
(文章开头介绍过)是通过基于引用计数的智能指针管理的,当引用计数清零即触发对象回收。当执行完if let Some((idx, _)) = pair
分支判断之后, child
局部变量结束了它的生命周期,其指向的内存区域就会被rust回收,进而完成对子进程的完整回收。
assert_eq!(Arc::strong_count(&child), 1);
则是用来保证,当前状态下,子进程的TaskControlBlockInner
对象,只存在一个引用,保证当child
生命周期结束时,子进程会被释放。
除此之外,sys_waitpid
还会将子进程中的状态码写回到用户态内存区域,告知父进程:子进程的退出状态。
在rCore的代码中,退出的进程将其下面的子进程加入到了initProc中(初始进程)。这是一个违背直觉的做法,因为每个进程的结构体中都存放了parent 进程的信息。那么为什么不能直接将子进程交付给父进程呢?
原因是可能存在的死锁问题,如下图所示:
进程A (正在退出) 进程P (A的父进程) 进程C (A的子进程)
---------------------- ---------------------- ----------------------
获取自己的锁 (A_lock) 正在执行 wait
获取自己的锁 (P_lock)
试图获取父进程P的锁 试图获取子进程A的锁
(等待 P_lock) (等待 A_lock)
... ...
死锁! 死锁!
为什么A进程会试图获取父进程的锁呢?原因在于子进程只保留Weak
类型的父进程指针(防止父子进程之间存在循环引用的问题)。当当前进程企图修改父进程时(将自己的子进程加入到父进程中),必须将Weak
指针升级为Arc
指针。在这个过程中就需要获得父进程的锁,进而导致上图所示的死锁问题。
因此,当任何进程退出时,将子进程交付给initProc进程,避免了死锁问题,也让子进程能够被妥善回收。在rCore中,initProc进程如下定义:
fn main() -> i32 {
if fork() == 0 {
// 子进程:执行 user_shell
exec("user_shell");
} else {
// 父进程:循环等待回收子进程
loop {
let mut exit_code: i32 = 0;
let pid = wait(&mut exit_code);
if pid == -1 { // 没有子进程了
// continue
}
println!("[initproc] Released a zombie process, pid={}, exit_code={}", pid, exit_code);
}
}
0
}
简单来说,initProc中产生shell子进程之后,主要在无限循环中调用wait
,适时回收挂载在initProc上的子进程。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。