【嵌入式开发——Linux操作系统】7进程管理
在其他操作系统诸如uCOS等,可能会听到任务的概念,在Linux对应的则是进程,进程描述符是task_struct。
0 task_struct
该描述符内容很多,不再展示,感兴趣的可以下载Linux内核源码查看,其包含的内容大致如下:
对于每个进程来说,Linux把两个不同的数据结构紧凑的存放在一个单独为进城分配的存储区域内:一个是内核态的进程堆栈,一个是紧挨着进程描述符的小数据结构thread_info,叫做线程描述符。
这块存储区域大小通常为8192个字节(两个页框),考虑到效率因素,内核让这8K空间占据连续两个页框,并让第一个页框起始地址是2^13的倍数。(较新的Linux源码中,task_struct没有包含thread_info对象,而是stack指针)
C语言使用联合体表示一个线程描述符和内核栈:
union thread_union {
struct thread_info thread_info;
unsigned long stack[THREAD_SIZE/sizeof(long)];
}
同时,thread_info中包含task_struct地址:
struct thread_info {
struct pcb_struct pcb;
struct task_struct *task;
unsigned int flags;
unsigned int ieee_state;
struct exec_domain *exec_domain;
mm_segment_t addr_limit;
unsigned cpu;
int preempt_count;
...
}
这样设计的好处是能让stack,thread_info,task_struct三者知道其中一个,就能快速访问到另外两个:
task_struct->stack能获得内核栈stack,而esp寄存器就在内核栈上,且因为thread_info和stack是联合体,thread_info还在stack最低处,且按照THREAD_SIZE(8K=213,或4K=212)对齐,也就是说很容易从esp寄存器的值获得当前在CPU上正在运行的thread_info结构的地址:即屏蔽掉esp的低13(或12)位即可,由current_thread_info()函数来完成,其产生汇编指令如下:
movl $0xffffe000, %ecx //4K内核栈对应0xfffff000
andl %esp, %ecx
movl %ecx, p
三条指令执行后,p就是当前CPU运行进程的thread_info结构指针。
知道thread_info结构地址后,也可以快速得到当前CPU上运行进程的描述符指针task_struct,内核调用current宏,本质就是current_thread_info->task。
1 进程状态
(1)可运行状态(TASK_RUNNING)
进程要么在CPU上执行,要么等待执行。
(2)可中断的等待状态(TASK_INTERRUPTIBLE)
进程被挂起(睡眠),直到某个条件为真。产生一个硬件中断,释放进程正在等待的系统资源,或传递一个信号都可以唤醒进程。
(3)不可中断的等待状态(TASK_UNINTERRUPTIBLE)
睡眠进程不能改变其状态,kill也无法杀死。该状态一般是等待特定I/O事件出现,最常见与硬件设备交互,比如read某个设备内容,read系统调用会最终执行设备驱动的代码并与物理设备交互,该过程任务可能需要被设为该状态,以避免与设备交互过程被打断,造成设备陷入不可控状态。这种情况下的TASK_UNINTERRUPTIBLE总是很短暂,通过ps命令基本捕捉不到。
(4)暂停状态(TASK_STOPPED)
状态执行被暂停,当进程收到SIGSTOP,SIGTSTP,SIGTTIN或SIGTTOU信号后,进入暂停状态。
(5)跟踪状态(TASK_TRACED)
被其他进程跟踪的进程,如debugger执行ptrac()监控一个测试程序。
常规五大状态就是上述状态,还有下面几个临时状态:
(6)僵死状态(TASK_DEAD–EXIT_ZOMBIE)
退出过程中处于TASK_DEAD状态,进程所有资源释放了,除了task_struct结构,内核栈,thread_info等信息,只剩空壳,后续通知父进程等待被销毁。
(7)即将销毁(TASK_DEAD–EXIT_DEAD)
该状态的进程,接下来会被彻底释放,task_struct等归还系统。
2 进程创建与销毁
Unix创建进程的方式:
(1)fork()通过拷贝当前进程创建一个子进程,子进程与父进程区别仅在于PID(每个进程唯一),和某些资源的统计量(如挂起的信号);
(2)exec()负责读取可执行文件并将其载入地址空间开始运行。exec()指所有exec()一族的函数,有execve(),execle(),execv(),execvp()等。
2.1 写时拷贝
传统的fork()系统把所有资源直接复制给新进程,这种实现过于简单且效率低下,因为其拷贝的数据也许并不共享,更糟的是,如果新进程打算立即执行一个新映像,之前所有拷贝都将前功尽弃。
Linux的fork()使用写时拷贝(cope-on-write)页实现,其是一种可延迟拷贝甚至免除拷贝的技术。内核并不复制整个进程地址空间,而是让父进程和子进程共享一个拷贝,只有某个进程试图写一个物理页,内核就把这个页的内容拷贝到一个新物理页,并把这个新物理页分配给正在写的进程。
写时拷贝原理
实际运用了一个“引用计数”的概念来实现,在开辟的空间中多维护4字节来存储引用计数,有两种方法:
1.多开辟4B的空间,用来记录有多少个指针指向这个空间;
2.在开辟空间的头部预留4B空间来记录有多少个指针指向这片空间。
当多开辟一份空间时,引用计数+1,如果有释放空间,引用计数-1,这不是真正释放,只有引用计数为0时,才会真正释放空间。如果有修改或写操作,那么也让原空间引用计数-1,并真正开辟新空间。
虚拟内存管理中的写时复制
一般把这种共享访问页面标记为只读,当一个task试图向内存中写入数据时,内存管理单元(MMU)抛出一个异常,内核处理该异常时为该task分配一份物理内存并复制数据到此内存,重新向MMU发出执行该task的写操作。
这种引用计数解决拷贝问题,C++中智能指针shared_ptr也是该原理。
fork(),vfork()库函数根据各自需要的参数标志去调用clone(),然后clone()调用do_fork()。
在Linux中,轻量级进程(ligtweight process)是由clone()函数创建,该函数有以下几个参数:
fn:指定一个由新进程执行的函数,当这个函数退出时,子进程终止,函数返回一个整数,表示子进程推出代码;
arg:指向传递给fn()函数的数据;
flags:各种各样的信息;
child_stack:用户态堆栈指针赋给子进程的esp寄存器,调用进程(指调用clone()的父进程)应该总是为子进程分配新的堆栈;
tls:表示线程局部存储段(TLS)数据结构的地址,该结构是为新轻量级进程定义的;
ptid:表示父进程的用户态变量地址,该父进程具有与新轻量级进程相同的PID;
ctid:表示新轻量级进程的用户态变量地址,该进程具有这一类进程的PID。
2.2 do_fork()
负责处理clone(),fork(),vfork(),执行时使用如下参数:
clone_flags:与clone()的参数flags相同;
stack_start:与clone()的参数child_stack相同;
regs:指向通用寄存器的指针,通用寄存器的值是在从用户态切换到内核态时被保存到内核堆栈中的;
stack_size:未使用(总是被设置为0);
parent_tidptr,child_tidptr:与clone()中的对应参数ptid和ctid相同。
do_fork()
利用辅助函数copy_process()
来创建进程描述符以及子进程执行所需要的所有其他内核数据结构,下面是do_fork()的主要执行步骤:
- 通过查找pidmap_array位图,为子进程分配新的PID;
- 检查父进程的ptrace字段(current->ptrace):如果值不为0,说明有另外进程在跟踪父进程;
- 调用copy_process()复制进程描述符;
- 如果设置了CLONE_STOPPED标志,或者必须跟踪子进程(即p->ptrace中设置了PT_PTRACED标志),那么子进程状态会被设置为TASK_STOPPED,并为子进程增加挂起SIGSTOP信号,直到被另一个进程唤醒;
- 如果没有设置CLONE_STOPPED标志,则调用wake_up_new_task()执行下述操作:
a. 调整父进程和子进程的调度参数;
b. 如果子进程将和父进程运行在用一个CPU上,而且父进程和子进程不能共享同一组页表(CLONE_VM标志被清零),那么就将子进程插入父进程运行队列,且恰好在父进程前,这样迫使子进程先于父进程运行,好刷新其地址空间,避免父进程先运行而带来写时拷贝不必要的复制操作;
c. 否则,如果子进程不和父进程在同一CPU上,或者父进程和子进程共享同一组页表(CLONE_VM标志被置位),那么就把子进程插入父进程运行队列的队尾。 - 如果父进程被跟踪,则子进程的PID会被存入current的ptrace_message字段,并向当前进程的父进程发送SIGCHLD信号,子进程的祖父进程是跟踪父进程的debugger进程,SIGCHLD信号通知debugger进程:current已经创建一个子进程,可以通过查找current_ptrace_message字段获取子进程PID;
- 如果设置了CLONE_VFORK标志,则把父进程插入等待队列,并挂起父进程直到子进程释放自己的内存地址空间(也即一直等子进程结束或执行新程序);
- 结束并返回子进程PID。
2.2.1 copy_process()
copy_process()创建进程描述符以及子进程执行所需的其他数据结构。它的参数与do_fork()参数相同,外加子进程的PID,下面描述copy_process()最重要的步骤:
- 检查参数clone_flags所传递标志的一致性,尤其是在以下情况,它返回错误代号:
a. CLONE_NEWNS和CLONE_FS标志都被设置;
b. CLONE_THREAD标志被设置,但CLONE_SIGHAND标志被清零(同一线程组中的轻量级进程必须共享信号);
c. CLONE_SIGHAND标志被设置,但CLONE_VM被清零(共享信号处理程序的轻量级进程也必须共享内存描述符)。 - 通过调用security_task_create()以及稍后调用的security_task_alloc()执行所有附加的安全检查。
- 调用dup_task_struct()为新进程创建一个内核栈,thread_info结构和task_struct,这些值与当前进程的值相同。此时,子进程和父进程的描述符完全相同。
- 检查并确保新创建的子进程后,当前用户所拥有的进程数目没有超出给它分配的资源限制。
- 子进程着手使自己与父进程区别开来。进程描述符内的许多成员被清0或设为初始值。那些不是继承而来的进程描述符成员,主要是统计信息。task_struct中大多数数据都依然不变。
- 子进程状态被设置为TASK_UNINTERRUPTIBLE,以保证它不会投入运行。
- copy_process()调用copy_flags()以更新task_struct的flag成员。标明进程是否拥有超级用户权限的PF_SUPERPRIV标志被清0,表明进程还没有调用exec()函数的PF_FORKNOEXEC标志被设置。
- 调用alloc_pid()为新进程分配一个有效的PID。
- 根据传递给clone()的参数标志,copy_process()拷贝或共享打开的文件,文件系统信息,信号处理函数,进程地址空间和命名空间等。在一般情况下,这些资源会被给定进程的所有线程共享;否则,这些资源对每个进程是不同的,因此被拷贝到这里。
- 最后,copy_process()做扫尾工作并返回一个指向子进程的指针。
再回到do_fork()函数,如果copy_process()函数成功返回,新创建的子进程被唤醒并让其投入运行,内核有意选择子进程首先执行。因为一般子进程都会马上调用exec()函数,这样可以避免写时拷贝的额外开销,如果父进程先执行,有可能开始向地址空间写入。
2.3 vfork()
除了不拷贝父进程的页表项外,vfork()系统调用和fork()的功能相同。和fork()区别:
fork()(传统的)子进程拷贝父进程的数据段和代码段,这里是通过拷贝页表实现的;
vfork()子进程和父进程共享地址空间,无需拷贝页表项,效率更高。
fork()父子进程的执行顺序不确定;
vfork()保证子进程先运行,在调用exec或exit之前与父进程数据共享。父进程在子进程调用exec或exit之后才被调度运行,如果在调用这两函数之前子进程依赖父进程的进一步动作,则会导致死锁。
2.4 内核线程
线程机制时现代编程技术中的一个抽象概念,该机制提供了同一程序内共享内存地址空间运行的一组线程。对Linux来讲,没有线程概念,其把所有线程都当做进程来实现,线程在LInux中仅仅被视为与其他进程共享某些资源的进程,每个线程都有自己的task_struct。Microsoft Windows,Sun Solaris等操作系统有自己专门的线程机制(这些系统长称线程为轻量级进程),假如有一个包含4个线程的进程,这些系统通常会由一个包含指向4个不同线程的指针的进程描述符,该描述符负责描述像地址空间,打开的文件这样的共享资源,线程然后再去描述其独占的资源。而Linux仅仅创建4个task_struct,创建时指定它们共享的资源就可以了。
传统的Unix系统把一些重要的任务委托给周期性执行的进程,这些任务包括刷新磁盘高速缓存,交换出不用的页框,维护网络连接等等。以严格线性方式执行这些任务效率不高,如果放在后台调度则是更好的处理办法。因为一些系统进程只运行在内核态,所以现代操作系统把他们的函数委托给内核线程(kernel thread),内核线程不同于普通进程如下:
- 内核线程只运行在内核态,没有独立的地址空间(实际指向地址空间的mm指针被设置为NULL),普通进程既可以运行在内核态也可以运行在用户态;
- 因为内核线程只运行在内核态,它们只使用大于PAGE_OFFSET的线性空间,从不切换到用户空间去;另一方面,不管是用户态还是内核态,普通进程可以用4G的线性空间。
2.4.1 创建线程
从创建线程的角度再来看clone方法,由于Linux创建新进程是从父进程fork()出来的,而fork调用clone,期间会由共享内存的信息。所以创建线程通过调用clone就简单多了,就是在调用clone()时候传递一些参数标志指明共享的资源:
clone( CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0);
上述调用父子进程共享地址空间,文件系统资源,文件描述符和信号处理程序,这父子进程就是所谓的线程。
内核线程通过调用kthread_create()创建,该函数调用clone()。
2.5 进程终结
进程的析构是自身引起的,发生在调用exit()系统调用时,既可以显示调用该函数,也可以隐式从某个主函数返回(C语音编译器会在main()函数返回后放置exit()的代码),exit()主要通过调用do_exit()完成以下工作:
- task_struct中的标志成员设为PF_EXITING;
- 调用del_timer_sync()删除一些内核定时器,根据返回结果,它确保没有定时器在排队,也没有定时器处理程序在运行;
- 如果是BSD的进程记账功能开启,do_exit()调用acct_update_integrals()来输出记账信息;
- 调用exit_mm()函数释放进程占用的mm_struct,如果没别的进程使用它们(即该地址空间没被共享),就彻底释放它们;
- 调用sem_exit()函数,如果进程排队等候IPC信号,它则离开队列;
- 调用exit_files()和exit_fs(),以分别递减文件描述符,文件系统数据的引用计数,如果某个引用计数降为0,则可以释放;
- 把存放在task_struct的exit_code成员中的任务退出码置为由exit()提供的退出码,或者去完成任何其他由内核机制规定的退出动作。退出码存放在这里供父进程随时检索;
- 调用exit_notify()向父进程发送信号,给子进程重新找养父(养父是线程组其他进程或init进程),并把进程状态(存在task_struct中的exit_state)设为EXIT_ZOMBIE;
- 调用schedule()切换到新进程,因为处于EXIT_ZOMBIE状态的进程不会被调度,所以这是进程所执行的最后一段代码,do_exit()永不返回。
至此,与进程相关的所有资源都被释放掉了,所占有的内存就剩内核栈,thread_info,和task_struct结构。此时进程存在的唯一目的就剩向它的父进程提供信息,父进程检索到信息后,通知内核那是无关的信息,由进程所持有的剩余内存被释放,归还系统。
3 进程调度
I/O消耗型进程和处理器消耗型进程
进程可被分为I/O消耗型和处理器消耗型。前者指进程大部分时间用来提交I/O请求或等待I/O请求。这样的进程经常处于可运行状态,但通常运行短短一会儿,等多时间用来等待I/O请求时被阻塞(这里所说的I/O是指任何类型可阻塞资源,如键盘输入或是网络I/O)。举例来说,多数用户图形界面程序GUI就是I/O密集型。
处理器消耗型进程时间大多数用在执行代码上,除非被抢占,否则它们通常一直不停地运行。它们没太多I/O请求,从系统响应速度考虑,调度器不应该经常让它们运行,对此类进程,调度策略往往是尽量降低它们的调度频率,延长其运行时间。如进行大量科学计算的程序。
当然这种划分方法不是绝对的,因为有些进程既是I/O消耗型又是处理器消耗型。
Unix系统的调度程序更倾向于I/O消耗型程序,以提供更好的程序响应速度。Linux为了保证交互式应用和桌面系统的性能,对进程调度做了优化(缩小响应时间),更倾向于优先调度I/O消耗型进程。
进程优先级
Linux采用两种不同的优先级范围,0-99是实时进程,100-139(映射到nice值范围是-20-19)给普通进程使用。
第一种nice值给普通进程使用,nice值越低,代表更高的优先级。进程有两个优先级,静态优先级和动态优先级。静态优先级是用来计算进程运行的时间片长度的,动态优先级是在调度器进行调度时用到的,调度器每次都选取动态优先级最低的进程运行。
静态优先级static_prio与nice值之间的关系:
静态优先级=100+nice+20;
运行时间片长度计算:
静态优先级<120,基本时间片= max((140-静态优先级)*20, MIN_TIMESLICE);
静态优先级>=120,基本时间片= max((140-静态优先级)*5, MIN_TIMESLICE);
动态优先级prio计算:
动态优先级= max(100, min(静态优先级 - bonus + 5, 139));
bonus范围是0-10,值小于5表示降低动态优先级以示惩罚,值大于5表示增加动态优先级以示奖赏。bonus值依赖进程过去的情况,与进程的平均睡眠时间相关。
第二种是实时优先级,默认情况下范围0-99,越高的实时优先级进程优先级越高,任何实时进程优先级都高于普通进程。
实时优先级rt_priority范围是0-99,只对实时进程有效:
prio = MAX_RT_PRIO - 1- rt_priority;
rt_priority值越大,则prio越小,所以rt_priority值越大,优先级越高。
Linux线程调度策略
SCHED_OTHER 分时调度策略(有SCHED_NORMAL, SCHED_BATCH)
SCHED_FIFO 实时调度策略,先到先服务
SCHED_RR 实时调度策略,时间片轮转
Linux调度器是以模块化方式提供的,这种模块化结构被称为调度器类(scheduler class)
SCHED_OTHER 属于CFS完全公平调度;SCHED_FIFO,SCHED_RR属于rt
实时调度策略
SCHED_FIFO 不使用时间片,处于任何可运行状态的SCHED_FIFO会比任何SCHED_NORMAL的进程优先调度,一旦一个SCHED_FIFO级的进程运行时,它就会一直运行下去,直到自己受阻或显式的释放处理器为止,或者被更高优先级的SCHED_FIFO或SCHED_RR任务抢占,如果有两个相同优先级的SCHED_FIFO,它们轮流执行。
SCHED_RR与SCHED_FIFO大体相同,只是进程在耗尽时间片后就不再继续执行了,且不会被抢占。
这两种实时算法都是静态优先级,内核不为实时进程计算动态优先级。保证给定实时进程总能抢占优先级比他低的进程。
Linux实时调度算法提供一个软实时工作方式,含义是:内核调度进程,尽力使进程在限定时间到来前运行。但内核不能保证总能满足这些进程的要求。相反,硬实时系统保证在一定条件下可以满足任何调度的要求。
4 Linux操作系统实时性的讨论
4.1 实时操作系统与非实时操作系统
实时操作系统
当外界事件或数据产生时,能够接受并以足够快的速度予以处理,其处理的结果又能在规定时间之内来控制生产过程或对处理系统做出快速响应。
实时操作系统是抢占式操作系统,如果你的进程优先级高,则肯定第一个得到执行,直至结束执行,中间的时间通过CPU频率等可以推算出来。
(1)多任务
系统提供多任务运行机制,系统内核通过调度让CPU运行许多外部事件线程,实现任务的并发性。
(2)抢占调度
系统具有继承的优先级和抢占式内核属性,在执行某一项任务时候,若有更高优先级的任务进入可执行态,系统会立即抢占当前CPU资源,退出低优先级任务,运行高优先级任务。
(3)任务间的通讯和同步
实时系统中,可能存在多任务执行,系统必须提供这些任务间的通讯机制,有效的共享不可抢占的资源或临界区所需要的同步机制。
(4)任务与中断之间的通信
真实场景中,事件通常作为中断方式到来,为了系统调度的稳定,提供有效的排队和减小中断服务程序的开销,通常希望在任务级线程处理相应工作,所以需要任务与中断之间的通信。
实时操作系统分为硬实时和软实时。硬实时是指在规定时间内必须完成任务,在操作系统设计时保证;软实时只要按照任务的优先级,尽可能完成任务操作即可。
硬实时操作系统:完全满足在指定时间内完成关键行为,常见硬实时操作系统有VxWorks, FreeRTOS, uCOS等。
软实时操作系统:大多数情况下在指定时间内完成关键行为。
非实时操作系统(分时操作系统)
诸如Windows/Linux等基于时间片轮转的操作系统是非实时操作系统,CPU不可抢占,从图中可知,即便高优先级任务就绪,也不能马上中断低优先级任务执行,而是必须等待低优先级任务主动挂起或时间片结束才能得到运行。所以使用PC时经常出现应用程序无响应的问题。
分时操作系统内核不可抢占,同时为多个用户任务服务,操作系统以时间片轮转机制,轮流执行任务,实现任务调度和执行,由于间隔时间很短,所以每个用户感觉独占计算机。
(1)交互性
用户和系统进行人机对话
(2)多路性
多用户在各自终端上使用同一个CPU。
(3)独立性
用户可以独立操作,互不干扰。
(4)及时性
用户可以在短时间内得到系统的及时应答。
分时操作系统基本设计原则是:尽量缩短系统平均响应时间并提高系统的吞吐率,在单位时间内为尽可能多的用户请求提供服务。与实时操作系统相比,非实时操作系统更关注系统的平均性能,在响应时间上,注重所有任务的平均响应时间,而不关心单个任务的响应时间。而实时操作系统要求每个实时任务在最坏情况下都要满足其实时性要求,即,实时操作系统注重的是个体表现,更准确地讲个体最坏情况表现。
Windows和Linux都是分时操作系统,一般采用公平调度算法,线程/进程一多,就要分享CPU时间,Linux下有针对“实时进程”的调度,调度算法和普通进程不一样,但也只是响应时间降低而已,不是真正的实时。
真正实时操作系统内核是可中断可抢占的,而非实时操作系统通常在执行内核功能时不可中断,Linux是软实时的,它在内核中加入若干可中断点,而不是任何时候都允许中断,内核中仍然有大量不可抢占区域。
虽然Linux进程调度也支持实时优先级,但缺乏实时任务的调度机制和调度算法;Linux的任务调度算法不唯一,如果有实时性高的任务,Linux可以勉强实现软实时调度,做不了硬实时。
4.2 实时操作系统和非实时操作系统差异
(参考:https://blog.csdn.net/bandaoyu/article/details/83311986)
(1)任务调度策略
Linux等分时操作系统,采用基于优先级的抢占式调度(和抢占式内核不同),对优先级相同的进程则采用时间片轮转调度方式,用户进程可以通过系统调用动态调整自己的优先级,操作系统也可根据情况调整某些进程的优先级。
实时操作系统任务调度可分为两种:静态表驱动和固定优先级抢先式调度。
(2)内存管理
为解决虚拟内存管理给系统带来的不可预测性,实时操作系统一般采用如下两种方式:
在原有虚存管理机制基础上增加页面锁功能,用户可将关键页面锁定在内存中,从而不会被swap程序将该页面交换出内存。这种方式既得到了虚存管理机制的好处,又提高了系统的可预测性。
采用静态内存划分方式,为每个实时任务划分固定的区域,优点是系统有较好的可预测性,缺点是灵活性不够好,任务对存储器需求一旦有变化就要重新对内存划分,虚存管理机制带来的好处也丧失了。