一、概述二、进程管理
2.1 进程描述符2.2 进程状态2.3 进程创建2.4 线程实现2.5 进程终结 三、进程调度
3.1 上下文切换 四、总结 一、概述
进程就是处于执行期的程序,通常包括内容:正文段、数据段、打开的文件、挂起的信号、内核内部数据、处理器状态,内存映射的地址空间、执行线程等。
线程是进程中活动的对象,每个线程都拥有一个独立的程序计数器、进程栈和一组进程寄存器。对于 Linux 来说,线程就是一种特殊的进程。
二、进程管理 2.1 进程描述符内核把进程放在任务队列(一个双向循环链表中),链表的每一项,都是一个 task_struct 成为进程描述符号,也就是进程控制块。包含一个具体进程的所有信息。大约有 1.7 KB 的大小。
在寄存器较少的体系结构中,一般将当前的 task_struct 放在栈尾部,保存在一个 thread_info 中,通过栈顶指针和偏移量快速获取。
struct thread_info {struct task_struct*task; //当前进程描述符void*dump_exec_domain; //备份可执行领域unsigned longflags; //标志intpreempt_count; //可抢占计数unsigned longtp_value;mm_segment_taddr_limit; //地址的界限struct restart_blockrestart_block; //信号的实现struct pt_regs*regs;unsigned intcpu; //标识当前cpu};
另外,Unix 进程都存在一个明显的继承关系,所有的进程都是 PID 为 1 的 init 进程的后代。
进程在系统启动的最后阶段启动 init 进程,该进程读取系统的初始化脚本,并执行其他的相关程序,最终完成整个系统的启动。
进程描述符也维护了进程的继承关系,保存了父进程的 ID 以及子进程的 list
struct task_struct __rcu*parent; //父进程引用struct list_headchildren; //子进程的链表struct list_headsibling; //兄弟进程,也就是相同父进程的进程struct task_struct*group_leader; //进程组的组长
2.2 进程状态进程中的 _state 描述了进程状态,Linux 的进程状态和传统操作系统五状态、三状态模型有一点区别。
系统中的每个进程都处于这五个状态之一,可以使用 ps -ef 或者 ps aux 查看进程状态。
TASK_RUNNING:运行态或者就绪态,唯一在用户空间或者内核空间执行的状态,对应 stat 的 R 状态。
TASK_INTERRUPTIBLE:可中断状态,浅度睡眠态,一般是主动进入睡眠等待某些条件达成,可以响应信号,而唤醒进入运行,对应查询的状态 S。
TASK_UNINTERRUPTIBLE:不可中断态,深度睡眠的状态,不会响应信号,也就是说,你发送一个 kill 的信号也不会将进程杀死。对应状态 D。(TASK_STOPPED 或 TASK_TRACED):表示进程被停止(往往是因为受到信号)或者因为被其他进程追踪而暂停,等待被追踪进程的操作,对应状态 T。TASK_ZOMBIE:僵尸态,子进程运行结束,父进程没有感知以及没有通过 wait 函数回收子进程资源,子进程游离在系统成为僵尸进程,对应状态 Z。
内核通过通过 set_current_state 方法设置进程的状态。
#define set_current_state(state_value)do {debug_normal_state_change((state_value));smp_store_mb(current->__state, (state_value));} while (0)
2.3 进程创建很多系统对于进程的创建提供了 spawn 的机制,也就是产生进程,包括,分配地址空间, 载入可运行文件,然后执行。而 Unix 又采用了不同的方式,将上述步骤分成 fork() 和 exec() 两部分,fork 通过拷贝父进程来创建一个子进程,而 exec 则读取可执行文件,并载入地址空间开始运行。
另外,为了 fork 之后快速的 exec,Unix 还提供了写时复制的机制,就是子进程fork父进程的时候,不会拷贝一个完全一样的副本,而是拷贝以及修改一些必要信息,共享其余的地址空间,并把空间的权限设置为只读,一旦有进程要进行修改操作,就会引发异常,此时才会拷贝一份需要修改的地址空间,通常以页为单位,这样就可以节省很多拷贝的空间以及时间。Redis 的 RDB 持久化就是使用这种机制的很好案例。
fork()
fork 在父进程调用一次,父子进程各返回一次,父进程返回子进程 ID,子进程返回 0,内核通过返回值的不同来区分父子进程从而执行不同的任务。
Linux 使用系统调用 clone() 来实现 fork() ,拷贝一个子进程大部分内容都由 copy_process() 完成:
1)首先会通过 dup_task_struct 备份当前进程,为新进程创建一个内核栈、thread info 以及 task struct,此时和父进程是完全相同的。2)然后就通过一些数据校验,将子进程和父进程区分,为子进程的数据清零或者初始化。3)根据 clone_flag 的参数标志,拷贝或共享打开的文件描述符、信号处理函数、进程地址空间、命名空间等。4)最后,返回一个指向子进程的指针。
fork 的子进程继承父进程的如下参数:
子进程不继承父进程的两者区别如下:
fork 主要用于以下两种情况:
vfork()
vfork 相对于 fork 的区别是,是否对父进程的页表进行复制,vfork 不能向地址空间中写入,并且保证父进程会在子进程 exec 或者 exit 后才会运行。为的是优化运行的效率,但是父进程依赖子进程的运行,可能导致死锁,以及现在写时复制的引入,所以最好不调用 vfork。
2.4 线程实现从 Linux 内核的角度来说,它没有线程的概念,它也有唯一隶属于它的 task_struct ,所以仅仅被视为一个和其他进程共享某些资源的特殊进程。
对比于其他对于线程有特殊实现的操作系统,需要维护一个进程空间,再维护指向该进程包含的线程,还要维护线程自己的结构。显然,Linux 只需要维护几个进程,并且制定他们共享哪些资源,实现的更高雅。
线程的创建也使用 clone() 系统调用来复制线程,只不过传入的参数有所不同,通过传入的参数来指明需要共享的资源。
创建线程使用:
clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
fork() 使用:
clone(SIGCHLD, 0);
vfork() 使用:
clone(CLONE_VFORK | CLONE_VM | SIGCHLD, 0);
每个参数表达的意思如下:
内核线程
内核需要在后台执行一些操作,这种任务就可以通过内核线程完成。内核线程也有一个存储信息的 task_struct。
内核线程和普通线程的区别在于,内核线程没有独立的地址空间,只在内核空间运行。
主要通过 kthread.h 中的 kthread_create、kthread_run 和 kthread_stop 来控制内核线程的创建,运行以及停止。
2.5 进程终结进程的终结一般使用 kernel/exit.c 下的 do_exit 函数来实现,源码大致如下:
void __noreturn do_exit(long code){struct task_struct *tsk = current;int group_dead;// 省略一些校验和特殊处理 // 将 task_struct 中的标志信号设置为 PF_EXITING exit_signals(tsk); // 一些需要特殊退出的情况 if (tsk->mm) sync_mm_rss(tsk->mm); // 输出记账信息 acct_update_integrals(tsk); group_dead = atomic_dec_and_test(&tsk->signal->live); // 进程组消亡的情况做一些特殊处理 if (group_dead) { if (unlikely(is_global_init(tsk))) panic("Attempted to kill init! exitcode=0x%08xn", tsk->signal->group_exit_code ?: (int)code);#ifdef CONFIG_POSIX_TIMERS // 删除内核定时器,以及取消一些定时处理程序hrtimer_cancel(&tsk->signal->real_timer);exit_itimers(tsk->signal);#endifif (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); // 把 exit_code 置为参数传入的 code ,表示该进程结束tsk->exit_code = code;taskstats_exit(tsk, group_dead); // 释放占用的 mm_struct (mm_struct 指的是进程的虚拟地址空间)exit_mm();if (group_dead)acct_process();trace_sched_process_exit(tsk);// 退出IPC信号等待exit_sem(tsk);// 释放 shm(shm 是基于内存的临时文件系统) 存储段exit_shm(tsk);// 释放递减文件描述符exit_files(tsk);// 递减文件系统数据的引用计数,变成 0 的话可以释放,上同exit_fs(tsk);if (group_dead)disassociate_ctty(1);// 释放任务的命名空间exit_task_namespaces(tsk);// 释放任务exit_task_work(tsk);// 释放该进程的线程exit_thread(tsk);// 做一些通知perf_event_exit_task(tsk);sched_autogroup_exit_task(tsk);cgroup_exit(tsk);flush_ptrace_hw_breakpoint(tsk);exit_tasks_rcu_start();// 向父进程发送信号,给子进程重新找养父,// 并设置进程状态为 EXIT_ZOMBIE 也就是僵尸进程exit_notify(tsk, group_dead);proc_exit_connector(tsk);mpol_put_task_policy(tsk);// 省略最后的一些校验和回收// 最后会调用 schedule() 切换其他进程运行}
子进程结束退出之后,父进程就可以通过 wait 或者 waitpid 来等待回收子进程资源,wait 会阻塞等待一直到第一个退出的进程,而 waitpid 会等待制定 pid 进程,不会阻塞。
wait 函数还是使用系统调用 wait4 来实现,最终需要释放文件描述符时,release_task 函数会被调用。
void release_task(struct task_struct *p){struct task_struct *leader;struct pid *thread_pid;int zap_leader;repeat:rcu_read_lock();dec_rlimit_ucounts(task_ucounts(p), UCOUNT_RLIMIT_NPROC, 1);rcu_read_unlock();cgroup_release(p); // 加锁write_lock_irq(&tasklist_lock);ptrace_release_task(p);// 获取进程的idthread_pid = get_pid(p->thread_pid);// 从 pidhash 以及任务列表中删除该进程// 释放所有剩余资源,并进行记录__exit_signal(p);zap_leader = 0;leader = p->group_leader;// 如果该进程是进程组的最后一个进程,则通知进程组 leader 进程的父进程回收资源if (leader != p && thread_group_empty(leader)&& leader->exit_state == EXIT_ZOMBIE) {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);// 释放内核栈、thread_info 所占的页以及 task_struct 所占的 slab 高速缓存// 至此,子进程的所有资源都被释放put_task_struct_rcu_user(p);p = leader;if (unlikely(zap_leader))goto repeat;}
总结过程如下:
另外,如果父进程在子进程之前退出,就会通过 exit_notify 找寻别的父进程,为同进程组的进程或者 init 进程,来保证僵尸进程不会一直游离在系统内浪费资源,找到父进程对资源进行回收。
三、进程调度进程调度算法就不多赘述了。
进程调度
3.1 上下文切换Linux 进程的上下文切换就是从一个可执行进程切换到另一个可执行进程,在 kernel/sched.c 中的 context_switch 函数实现,当内核调用 schedule 运行进程的时候,就会调用该函数。
主要就做了两步操作:
通过 switch_mm 函数切换虚拟内存;通过 switch_to 切换处理器状态;
static inline task_t * context_switch(runqueue_t *rq, task_t *prev, task_t *next){struct mm_struct *mm = next->mm;struct mm_struct *oldmm = prev->active_mm;if (unlikely(!mm)) {next->active_mm = oldmm;atomic_inc(&oldmm->mm_count);enter_lazy_tlb(oldmm, next);} else // 切换新的 mm_struct 也就是虚拟内存switch_mm(oldmm, mm, next);if (unlikely(!prev->mm)) {prev->active_mm = NULL;WARN_ON(rq->prev_mm);rq->prev_mm = oldmm;}// 切换处理器状态,包括栈,寄存器等信息的保存和恢复switch_to(prev, next, prev);return prev;}
另外,内核需要知道什么时候应该调用 schedule,所以内核提供了一个 need_resched 的标志来表明是否需要重新执行一次调度,当高优先级进程进入可执行状态,或者进程中断返回的时候,都会设置该标志。
四、总结进程是一个正在运行程序的实例,是操作系统分配资源的基本单位,进程包括正文段,数据段,堆栈,打开的文件,挂起的信号,处理器状态等内容;在 linux 中,进程包含一个唯一的 task_struct 结构体来修饰,一般通过 fork() 和 exit() 来创建和销毁。
线程是运行在进程中的一段逻辑流,是操作系统调度的基本单位,共享进程的资源。在 Linux 中,并没有线程的单独概念,它也包含一个 task_struct 的结构,被看作是一个与多个进程共享资源的特殊进程,它的创建也和 fork 类似,使用 clone() 的系统调用创建,不过会多加入一些参数来标识需要共享哪些资源。
内核线程没有独立的地址空间,只在内核空间运行。