1 进程
进程指的是处于执行期的程序。但是需要注意的是进程并不仅仅包括一段可执行程序的代码,它同时还包括其他资源,例如打开的文件,挂起的信号,内核内部数据,处理器状态,具有内存映射的地址空间和执行线程以及数据段等。
1.1 进程描述符
一个操作系统如果想管理好进程,那么操作系统就需要这个进程的所有信息,Linux内核成功抽象了进程这一概念,然后使用task_struct即进程描述符来对进程进行管理,同时内核使用双向链表(即任务队列)对进程描述符进行了相应的组织。(task_struct结构体定义在<linux/sched.h>)。
task_struct在32位计算机中占有1.7KB。包含一个进程所有的信息,包含打开的文件,进程地址空间,挂起的信号,进程状态等,具体可以参照在Linux内核代码中定义的task_struct结构体代码。Linux在分配进程描述符时,使用了slab机制(可以查看进程创建一节)。当进程描述符task_struct分配完毕之后,需要对其进行存放。
1.2 内核进程操作
对于一个进程来说,在内存中会分配一段内存空间,一般来说这个空间为1或者2个页,这个内存空间就是进程的内核栈。在进程内核栈的栈底有一个结构体变量为thread_info,这个结构体变量中包含了一个指向该进程描述符task_struct的指针,这个变量的存在,可以使内核快速地获得某一个进程的进程描述符,从而提高响应速度。在x86体系结构中,内核中的current宏就是通过对于这个结构体的访问来实现的,而在其他寄存器丰富的体系结构中看,可能会没有使用thread_info结构体,而是直接使用某一个寄存器来完成例如PPC体系结构。
/*x86中thread_info的定义*/
struct thread_info {
struct task_struct *task; /* main task structure */
struct exec_domain *exec_domain; /* execution domain */
unsigned long flags; /* low level flags */
unsigned long status; /* thread-synchronous flags */
__u32 cpu; /* current CPU */
int preempt_count; /* 0 => preemptable, <0 => BUG */
mm_segment_t addr_limit; /* thread address space:
* 0-0xBFFFFFFF for user-thead
* 0-0xFFFFFFFF for kernel-thread
*/
struct restart_block restart_block;
__u8 supervisor_stack[0];
};
1.3 进程PID
Linux的内核使用PID来对进程进行唯一标识。PID是pid_t的隐含类型,PID的值受到<linux/threads.h>头文件中规定的最大值的限制,但是为了和传统的Unix操作系统兼容,PID会被默认设置为32768即short int短整型的最大值。PID的最大值是系统中允许同时存在的进程的最大数目。PID 的最大值可以通过/proc/sys/kernel/pid_max来修改。
1.4 进程家族树
Linux和Unix系统一样,进程之间存在明显的继承关系。所有的进程都是PID为1的init进程的后代。内核会在系统启动的最后阶段启动init进程,这个进程回去读取并且执行系统的初始化脚本(initscript)执行相关程序,完成整个系统的启动。
在Linux操作系统中,每个进程都会有父进程,每个进程都会有0到n个子进程。同一个父进程的所有进程被称为兄弟。进程描述符中,包含了指向父进程的指针,还包含了一个children子进程链表(init进程的进程描述符是静态分配的)。所以通过简单的遍历就可访问到系统中的所有进程。在代码中特意提供了for_each_process(task)宏来进行对整个进程队列(或称任务队列)的访问能力。
2 进程创建
2.1 创建过程
在Linux进程创建不同于其他操作系统,Linux操作系统提供了两个单独的函数完成进程的创建工作。其中fork()函数通过拷贝完成子进程的创建,子进程会完全拷贝父进程中的绝大多数资源,(除了PID和PPID,以及一些敏感资源和统计量)。然后在使用exec()函数完成可执行文件的读取,并且将其载入地址空间运行。而其他操作系统一般只使用一个函数完成上述的两步操作。
fork()函数是通过clone()系统调用实现的。此调用会通过一系列参数标志指明父子进程需要共享的资源。库函数根据参数标志调用clone()。clone()调用do_fork()函数。do_fork()函数在kernel/fork.c中定义,并且完成了创建中的大部分工作。然后该函数会去调用copy_process()函数。copy_process()函数完成了下述工作:
1) 调用duo_task_struct()函数为新进程创建内核栈、thread_info、和task_struct,但是这些值都和当前进程的相同,只是一份简单的复制。
2) 检查当前用户的进程总数是否超过限制
3) 将进程描述符中关于目前进程的统计信息清零,使得子进程和父进程能够进行区分
4) 将子进程状态设为TASK_UNINTERRUPTIBLE,使其不能运行
5) 调用copy_flag()函数更新task_struct的flags成员。将超级用户权限标志符PF_SUPERPRIV清零,然后将进程未调用exec()函数标志位PF_FORKNOEXEC置位。
6) 调用alloc_pid()为新进程分配一个有效PID
7) 根据传递给clone()的参数标志,该函数(即copy_process()函数)拷贝或者共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间。
8) 扫尾,然后返回一个指向子进程的指针
当然还有其他形式的fork()函数实现方式。例如vfork()函数功能和fork()函数相同,但是vfork()函数不会拷贝父进程的页表项。vfork()生成的子进程作为一个单独的线程在其地址空间内运行,父进程会被阻塞,直到子进程退出或者调用exec()函数,子进程不允许向地址空间内写入数据。但是在使用了写时拷贝技术之后,这一项技术其实已经无关紧要了。
2.2 进程创建优化
由于进程描述符task_struct是一个在进程创建时必须的数据结构,所以进程的创建速度可以通过加速进程描述符的创建来提高,有鉴于此,内核使用了slab机制来对其进行处理。所谓slab机制,就是对于频繁被使用的数据结构,会进行缓存,而不是使用完毕之后直接进行释放。这样做的好处是,如果需要频繁创建某一数据结构变量,只是直接使用即可,而不需要进行内存的申请,使用完毕也不需要释放,大大减少了分配内存和回收内存的时间。使用slab机制后,进程描述符可以被快速地建立,同时进程销毁时也不需要去进行进程描述符的内存释放。
当然Linux内核在其他方面也使用了加快进程创建的方法。上面讲到,Linux创建进程使用fork()函数来完成,而fork()函数又使用clone()系统调用来实现,但是需要注意的是,创建一个新进程时,Linux内核加入了写时拷贝机制来加速进程的创建,而不是完整地对进程所有内容进行简单的复制。所谓写时拷贝就是在新进程创建时,子进程和父进程共享一个进程地址空间拷贝,当子进程或者父进程对这个拷贝执行写入操作后,数据才会被复制,然后进行各自的修改,所以资源在未进行写入时,以只读方式共享。这种写时拷贝的方式,将进程的创建开销从子进程对父进程资源的大量复制,简化为复制父进程的页表和子进程唯一进程描述符的创建。
2.3 进程终结
进程终结时,内核必须要释放他所占用的资源,然后通知父进程。进程的析构发生在exit()系统调用时,可以是显式的,也可以是隐式的,例如从某个程序的主函数返回(对于C语言来说其实会在main()函数的返回点后面设置exit()代码)。当进程收到不能处理但是又不能忽视的信号或者出现异常时,也可能会被动终结。但是进程在终结是,大部分还是会调用do_exit()完成(在kernel/exit.c中定义)。
(1) 将task_struct中的标志成员设置为PF_EXITING
(2) 调用del_timer_sync()删除任意内核定时器。根据返回的结果确认没有任何定时器在排队,同时也没有任何定时器处理程序在运行。
(3) 若开启了BSD的进程记账功能,那么还需要调用acct_update_integrals()来输出记账信息
(4) 调用exit_mm()释放进程占用的mm_struct,若是没有其他进程使用这个地址空间,那么就彻底释放此地址空间
(5) 调用sem_exit()函数,若进程排队等待IPC信号,则离开队列
(6) 调用exit_file()和exit_fs(),分别递减文件描述符、文件系统数据的引用计数。若释放后引用计数为0,则直接释放。
(7) 将存放在task_struct的exit_code成员中的任务退出代码置为由exit()提供的任务退出代码,或者完成任何其他由内核机制规定的退出动作。退出代码的存放是为了供父进程检索
(8) 调用exit_notify()函数向自己的父进程发送信号,并且给自己的子进程重新寻找养父,养父为线程组中的其他线程或者为init进程,然后将进程状态置为EXIT_ZOMBLE。
(9) do_exit()调用schedule()切换到新进程。这是do_exit()执行的最后代码,退出后就不再返回。
2.3.1 删除进程描述符
进程在执行完do_exit()函数调用之后,会处于EXIT_ZOMBIE退出状态,其所占有的内存就是内核栈、thread_info结构和task_struct结构体。处于这个状态的进程唯一目的就是向父进程提供信息。父进程检索到信息或者通知内核那是无关的信息后,由进程所持有的剩余的内存释放。
调用do_exit()之后,虽然线程已经僵死不再运行,但是系统还保留了它的进程描述符。这样做可以使系统能够在子进程终结后仍获取其信息。所以进程的终结清理操作可以和进程描述符的删除操作分开运行。
在删除进程描述符的时候,会调用release_task(),完成以下操作:
(1)调用__exit_signal(),由次函数调用_unhash_process(),后者又调用detach_pid()从pidhash上删除该进程,同时从任列表中删除该进程
2)__exit_signal()释放目前僵死进程所使用的所有剩余资源,并进行最终的统计和记录。
3)如果这个进程是进程组最后一个进程,并且领头进程已经死掉,那么release_task()通知僵死的领头进程的父进程
4)调用put_task_struct()释放进程内核栈和thread_info结构所占用的页,释放task_struct所占的slab高速缓存。
若父进程在子进程之前退出,则首先会为子进程在当前进程组内宣召一个进程作为父亲,若不行,就让init进程作为父进程。
3 线程
线程是指在进程中活动的对象,相对而言,线程仅仅局限在进程内部,线程拥有的资源远远比进程小,仅仅包括独立的程序计数器和进程栈以及一组进程寄存器。在其他操作系统中进程和线程的概念往往会被严格区分,但是对于Linux操作系统内核而言,它对线程和进程并不进行区分,线程通常被视为一个与其他进程共享某些资源的进程。每个线程都拥有自己的task_struct,所以线程在Linux内核中也被视为一个进程,这是和其他操作系统截然不同的。
线程的创建和进程是类似的但是在调用clone()的时候,会传递一些特殊的标志位,例如CLONE_VM,CLONE_FS,CLONE_FILES,CLONE_SIGHAND,这些值都是由下表定义的。
内核很多时候还需要在后台执行一些操作,这些都是由内核线程(kernel thread)完成。内核线程独立于内核进程运行,同时内核线程没有独立的地址空间,并且不会切换到用户空间,其他和普通线程一样,没有区别。
内核线程一般是自动从内核进程中衍生而出,同样内核线程也是通过clone()系统调用实现,并且需要调用wake_up_process()函数来进行明确地唤醒。kthread_run()可以完成线程的唤醒和运行,但是本质上只是调用了kthread_create()和wake_up_process()。内核线程可以使用do_exit()函数退出,也可以由内核其他部分调用kthread_stop()函数来进行退出。
4 进程和线程的区别
对于Linux内核而言,进程和线程没有区别。对于Linux内核而言,并没有对线程进行特殊处理,而是将线程与进程同等对待,这与其他操作系统完全不同。其他操作系统都提供了专门的机制去实现多线程机制,由于Linux强大轻便快速的进程创建手段,所以Linux仅仅将线程看作是进程共享了进程资源的多个进程,对于Linux内核来说创建线程等价于创建一个进程。通过Linux内核可以得知,一个进程的多线程其实只是共享了很多资源,例如地址空间等。由此产生了“Linux没有多线程机制“”这一说法,但是本质上来说,并不是Linux没有多线程机制,只是其实现方式和其他操作系统不同而已。
这是个人在阅读《Linux内核设计与实现》时候的一点心得,里面加入了一些自己关于操作系统的理解,对自己的现有的知识进行梳理,如有错误敬请指正。