进程描述符及任务队列
内核把进程放在叫做任务队列的双向循环链表中,链表中的每一项都是类型为task_struct
的进程描述符。进程描述符包含的数据可以完整的描述一个正在执行的程序:打开的文件、进程的地址空间、挂起信号、进程状态等
进程描述符的存放
内核通过PID
标识每个进程,pid_t pid
其中的pid_t
为int
类型,为了与老版本兼容,限制其大小为32767
即short int
的最大值。我们可以自己修改pid
的最大值来提高上限。
在内核中,访问任务通过指向该task_structa
的指针,这是通过current 宏
实现的。基于硬件的不同,该宏的实现也不同。有的体系可以专门拿出一个寄存器来存放指向当前进程的指针,用于加快访问速度。而像x86
这样的体系结构,其寄存器不多,只能在内核栈的尾端创建thread_info
结构,通过计算偏移量来间接得到当前的task_struct
进程状态
TASK_RUNNING(运行)
:进程是可执行的状态(运行或者就绪态都算)TASK_INTERRUPTIBLE(可中断)
:进程正被阻塞,等待某些情况达成,内核会把进程设置为运行。接收到信号也会被提前唤醒并投入运行TASK_UNINTERRUPTIBLE(不可中断)
:接收到信号也不会被唤醒TASK_ZOMBIE(僵死)
:该进程结束,但是父进程没有调用wait
系统调用回收资源。该进程描述符仍然保留,资源未被释放TASK_STOP PED(停止)
:进程停止运行,常发生在接收SIGSTOP SIGSTP SIGTTIN SIGTTOU
等信号的时候,调试期间接收任何信号也会
设置当前进程状态
内核经常会调整某个进程的状态,会使用set_task_state(task, state)
函数,将指定进程设置为指定状态
进程上下文
进程创建
- 其他操作系统:提供
产生
进程的机制,在新的地址空间创建进程,读入可执行文件,然后执行 Unix
:将上述步骤分成fork
和exec
来执行fork
通过拷贝当前进程创建一个子进程,子进程和父进程PID PPID 某些资源的统计量(比如挂起的信号)
不一样exec
函数负责读取可执行文件并将其载入地址空间开始运行
写时拷贝
传统的fork
直接拷贝所有资源给子进程,着过于简单且效率低下,慢且浪费资源
Linux
的fork
使用写时拷贝
实现,写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核并不复制整个地址空间,而是父子进程共享同一个拷贝,只有在需要写入的时候,才会复制(在此之前是以只读方式共享)。
通常来说,我们fork
得到的子进程都会直接执行另一个程序,所以实在没有必要再将父进程的所有数据复制到子进程,因为这些数据很大可能就没有被使用。比如fork
之后立即调用exec
,那么就无需复制操作了。这样子fork
的实际开销就是复制父进程的页表以及给子进程创建task_struct
fork()
Linux
通过调用clone
系统调用来实现fork
,clone
通过一系列参数标志来指明父子进程需要共享的资源。fork
,vfork
和_clone
库函数都根据各自需要的参数来调用clone
,再由clone
去调用do_fork
do_fork
完成了创建进程的大部分工作,该函数调用copy_process()
函数,然后让进程开始运行
copy_process()
函数的工作
- 调用
dup_task_struct()
为新进程创建一个内核栈、thread_info结构、task_struct
,这些值和当前进程值相同,父子进程的描述符是完全相同的 - 检查新创建的子进程,当前用户所拥有的进程数目有没有超过资源限制
- 为使父子进程区分开, 进程描述符许多成员需被清0
- 子进程的状态被设置为
TASK_UNINTERRUPTIBLE
以保证它不会投入运行 - 调用
copy_flags()
以更新task_struct
的flags
成员, - 调用
get_pid()
为新进程获取一个PID
- 根据传递给
clone()
的参数,拷贝或共享打开的文件、文件系统信息、信号处理函数、进程地址空间和命名空间等 - 让父进程和子进程平分剩余的时间片
- 做完最后的处理工作后,返回一个指向子进程的指针
如果copy_process()
函数成功返回,新创建的子进程被唤醒并被投入运行。内核一般选择子进程先执行,因为子进程一般会调用exec()
函数,这样可以避免拷贝操作。而父进程有可能会调用写操作,造成写时拷贝操作。
线程在Linux中的实现
其他的操作系统,比如Microsoft Windows
在内核中提供了专门支持线程的机制,但是Linux
没有。从内核角度,Linux
中没有线程这个概念。Linux
将线程当作进程来实现,并没有使用特殊的调度算法或定义特别的数据结构来表示线程,线程仅仅被视为一个使用某些共享资源的进程。每个线程都有唯一的task_struct
对于Linux
来说,这只是一种用来进程间资源共享的手段。
假设有一个包含四个线程的进程
- 支持线程的系统:会有一个包含指向四个线程的指针的进程描述符,该描述符描述像地址空间,打开的文件这样的共享资源。线程本身再去描述它独占的资源
Linux
:它仅仅创建四个进程并分配四个普通的task_struct
结构,建立这四个进程时指定它们共享某些资源
内核线程
- 内核需要在后台执行一些任务,这种任务通过内核线程实现——在内核运行的标准进程
- 内核线程和普通的进程间的区别在于内核线程没有独立的地址空间,它们只在内核空间运行,不会切换到用户空间
- 一般情况下,内核线程会将它在创建时得到的函数永远执行下去,该函数通常由一个循环构成,在需要的时候,这个内核线程会被唤醒,执行完毕则自行休眠
进程终结
当一个进程终结时,内核必须释放它所占有的资源并通知其父进程
- 一般来说,进程的析构发生在它调用
exit()
函数之后 exit()
函数可以显示调用也可以隐式调用(C语言编译器会在main()
函数的返回点后面放置调用exit()
的代码)- 当进程收到不能处理也不能忽略的信号后,也会被动终结
进程的终结大部分靠调用do_exit()
函数来完成
至此,与进程相关的资源都被释放掉了,进程不可运行并处于TASK_ZOMBIE
状态。它所占用的所有资源就是保存thread_info
的内核栈和保存task_struct
结构的那一小片slab
。此时进程存在的唯一目的就是向它的父进程提供信息
删除进程描述符
在调用了do_exit()
之后,尽管线程已经僵死不能运行,但是系统还是保留了它的进程描述符。不一次性处理完是为了让系统在子进程终结后仍能得到它的信息。因此,进程终结的清理工作和进程描述符被删除是分开执行的。
wait()
这一族函数都是通过唯一的系统调用wait4()
实现的,它将挂起调用它的进程,直到其中的一个子进程推出。此时函数会返回该子进程的PID
。
孤儿进程
如果父进程在子进程退出之前退出,必须有机制来保证子进程找到一个新的父亲,否则这些孤儿进程就会在退出时永远处于僵死状态,这样会浪费内存(没有父进程调用wait4()
函数回收资源)。
我们让init
做它们的父进程,在do_exit()
中会调用notify_present()
,该函数会通过forget_original_parent
来执行寻父过程