多任务处理是指用户可以在同一时间内运行多个应用程序,每个正在执行的应用程序被称为一个任务。Linux就是一个支持多任务的操作系统,比起单任务系统它的功能增强了许多。
多任务操作系统使用某种调度策略(可以查看操作系统来了解)支持多个任务的并发执行。事实上,(单核)处理器在某一时刻只能执行一个任务。每个任务创建时被分配时间片(几十到上百毫秒),任务执行(占用CPU)时,时间片递减。操作系统会在当前任务的时间片用完时调度执行其他的任务,由于任务会频繁的切换执行,且分配的时间片都是几十到上百毫秒,因此给用户多个任务同时运行的错觉。
多任务操作系统支持多个任务并发执行。每个任务创建时被分配时间片,任务执行时,时间片递减,操作系统调度进程是随机的。
多任务操作系统中通常有三个概念:任务、进程和线程。
1、任务
任务是一个逻辑概念,指由一个软件完成的活动,或者是实现某个目的的一系列操作。通常一个程序的一次运行就代表一个任务,一个任务包含一个或多个完成独立功能的子任务,这个独立的子任务就是进程或线程。例如QQ的一次运行就是一个任务。一个杀毒软件的一次运行是一个任务,目的是在各种病毒的侵害中保护计算机系统,这个任务包含多个独立功能的子任务(进程或线程),包括实时监控功能、定时查杀功能、防火墙功能以及用户交互功能等。
任务、进程和线程之间的关系如下图所示:
2、进程
2.1、进程的基本概念
进程是指一个具有独立功能的程序在某个数据集合上的一次动态执行过程,它是操作系统进行资源分配和调度的基本单位。一个任务的运行可以激活若干个进程,这些进程来互相合作完成该任务的功能。
进程是一个独立的可调度的任务,是一个抽象实体。当系统在执行某个程序时,分配和释放的各种资源。
进程是一个程序的一次动态执行过程。
进程是程序执行和资源管理的最小单位。
进程具有并发性、动态性、交互性和独立性等主要特征:
(1)并发性:指的是系统中多个进程可以同时并发执行,相互之间互不受干扰。
(2)动态性:指的是进程具有完整的生命周期,而且在进程的生命周期内,进程的状态是不断变化的,另外,进程具有动态的地址空间(包括代码、数据和进程控制块等)。
(3)交互性:指的是进程在执行过程中可能会与其他进程发生直接或间接的通信(IPC),如进程同步或进程互斥等,需要为此添加一定的进程处理机制。
(4)独立性:指的是进程是一个相对完整的资源分配和调度的基本单位,各个进程的地址空间是相互独立的,因此,只有采取某些特定的通信机制才能实现进程之间的通信。
进程与程序的区别:
程序是一段静态的代码,是保存在非易失性存储器上的指令和数据的有序集合,没有任何执行的概念;
进程是一个动态的概念,它是程序的一次执行过程,进程具有生命周期,包括了动态创建、调度、执行和消亡的整个过程,它是程序执行和资源管理的最小单位;
从操作系统的角度来看,进程是程序执行时相关资源的总称。当进程结束时,所有资源被操作系统自动回收。
Linux系统中主要包括下面几种类型的进程:
(1)交互式进程:这类进程经常与用户进行交互,需要等待用户的输入(键盘和鼠标操作等)。当接收到用户的输入之后,这类进程能够立刻响应。交互式进程可以在前台运行也可以在后台运行。典型的交互式进程有shell命令进程、vim文本编辑器、图形应用程序运行等。
(2)批处理进程:这类进程不必与用户进行交互,它不属于某个终端,它被提交到一个队列中以便顺序执行。因此通常在后台运行。因为这类进程不必很快的响应,因此往往不会优先调度。典型的批处理器是编译器的编译操作、数据库搜索引擎等。
(3)守护进程(精灵进程、后退进程、Daemon进程):这类进程一直在后台运行,和任何终端都无关联。一般在系统启动时开始执行,系统关闭时结束。很多系统进程(各种服务)都是以守护进程的形式存在。在Linux中,init是0号进程,它是所有进程的父进程。
2.2、Linux下的进程结构
Linux中的进程包含三个段:
(1)数据段:存放的全局变量、常数以及动态数据分配的数据空间(如malloc函数取得的空间)等。
(2)正文段:存放的是程序的代码。
(3)堆栈段:存放的是函数的返回地址、函数的参数以及程序中的局部变量。
进程不仅包括程序的指令和数据,而且包括程序计数器值、CPU的所有寄存器值以及存储临时数据的进程堆栈。
因为Linux是一个多任务的操作系统,所以其它的进程必须等到操作系统将处理器使用权分配给自己之后才能运行。当正在运行的进程需要等待其它的系统资源时,Linux内核将取得处理器的控制权,按照某种调度算法将处理器分配给某个正在等待执行的进程。
内核将所有进程存放在双向循环链表(进程链表)中,链表的每一项都是task_struct,称为进程控制块的结构。该结构包含了与一个进程相关的所有信息,在<include/Linux/sched.h>文件中定义。task_struct内核结构比较大,它能完整的描述一个进程,如进程的状态、进程的基本信息、进程标识符、内存相关信息、父进程相关信息、与进程相关的终端信息、当前工作目录、打开的文件信息、所接收的信号信息等。
下面讲述task_stuct结构中最为重要的两个域:state(进程状态)和pid(进程标识符)。
(1)进程运行状态
Linux中的进程运行状态有以下几种主要状态:
A、 运行状态(TASK_RUNNING):程序当前正在运行,或者在运行队列中等待调度。
B、可中断的阻塞状态(等待状态)(TASK_INTERRUPTIBLE):进程处于阻塞(睡眠)状态,正在等待某些事件的发生或者能够占用某些资源。处在这种状态下的进程可以被信号中断。接收到信号或被显示的唤醒呼叫(如调用wake_up系列宏:wake_up、wake_up_interruptible等)唤醒之后,进程将转变为运行(TASK_RUNNING)状态。
C、不可中断的阻塞状态(等待状态)(TASK_UNINTERRUPTIBLE):该状态类似于可中断的阻塞状态(TASK_INTERRUPTIBLE),但是它不会处理信号,把信号传递到这种状态下的进程不能改变它的状态。在一些特定的情况下(进程必须等待,知道某些不能被中断的事件发生),这种状态是很有用的。它不能被信号唤醒,只有它所等待的事件发生时,进程才能被显示唤醒呼叫唤醒。
D、暂停状态(TASK_STOPPED):进程的执行被暂停,当进程收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTOU等信号,就会进入暂停状态。
E、僵尸状态(TASK_ZOMBLE):子进程运行结束,父进程未退出,并且未使用wair函数族(如使用waitpid()函数)等系统调用来回收子进程的退出状态。处在该状态下的子进程已经放弃了几乎所有的内存空间,没有任何可执行代码,也不能被调度,仅仅在进程列表中保留一个位置,记载该进程的退出状态等信息供其父进程收集。
F、消亡状态(死亡状态)(TASK_DEAD):这是最终状态,这是一个已终止的进程,但还在进程向量数组中占有一个task_struct结构。父进程调用wait函数族回收之后,子进程由系统彻底删除,不可见。
它们之间的转换关系如下图所示:
内核可以使用set_task_state和set_current_state宏来改变指定进程的状态和当前执行进程的状态。
(2)进程标识符
Linux内核通过唯一的进程标识符PID来标识每个进程。PID存放在进程描述符的pid字段中,新创建的PID通常是前一个进程的PID加1,不过PID的值有上限(大值 = PID_MAX_DEFAULT – 1,通常为32767),读者可以查看/proc/sys/kernel/pid_max来确定该系统的进程数上限。
当系统启动后,内核通常作为某一个进程的代表。一个指向task_struct的宏current用来记录正在运行的进程。current经常作为进程描述符结构指针的形式出现在内核代码中,例如,current->pid表示处理器正在执行的进程的PID。当系统需要查看所有的进程时,则调用for_each_process()宏,这将比系统搜索数组的速度要快得多。
Linux中主要的进程标识为进程号(process identity number,PID)和父进程号(parent process ID,PPID),PID唯一的标识一个进程。在Linux中获得当前进程的进程号(PID)和父进程号(PPID)的系统调用函数分别为getpid()和getppid()。
2.3、进程的模式
进程的执行模式分为用户模式和内核模式:
用户模式与内核模式切换:
2.4、进程的创建、执行和终止
(1)进程的创建和执行
许多操作系统提供的都是产生进程的机制,也就是说,首先在新的地址空间里创建进程、读入可执行文件,后再开始执行。Linux中进程的创建很特别,它把上述步骤分解到两个单独的函数中去执行:fork()和exec函数族。首先,fork()函数通过复制当前进程创建一个子进程,子进程与父进程的区别仅仅在于不同的PID、PPID和某些资源及统计量。exec函数族负责读取可执行文件并将其载入地址空间开始运行。
要注意的是,Linux中的fork()函数使用的是写时复制页的技术,也就是内核在创建进程时,其资源并没有被复制过来,资源的赋值仅仅只有在需要写入数据时才发生,在此之前只是以只读的方式共享数据。写时复制技术可以使Linux拥有快速执行的能力,因此这个优化是非常重要的。
(2)进程的终止
进程销毁和进程创建同等重要,如果未认真对待进程销毁,它们将变成僵尸进程困扰各位。进程终结也需要做很多烦琐的收尾工作,系统必须保证回收进程所占用的资源,并通知父进程。Linux首先把终止的进程设置为僵尸状态,这时,进程无法投入运行,它的存在只为父进程提供信息,申请死亡。父进程得到信息后,开始调用wait函数族,后终止子进程,子进程占用的所有资源被全部释放。
进程和僵尸进程
文件操作中,关闭文件和打开文件同等重要。同样,进程销毁也和进程创建同等重要。如果未认真对待进程销毁,它们将变成僵尸进程困扰各位。
2.5、僵尸进程
进程完成工作后(执行完main函数中的程序后)应被销毁,但有时这些进程变成僵尸进程,占用系统中的重要资源。这种状态下的进程称作“僵尸进程”,这也是给系统带来负担的原因之一。因此,我们应该消灭这种进程。
(1)产生僵尸进程的原因
为了防止僵尸进程的产生,先解释产生僵尸进程的原因。利用如下两个示例展示调用fork函数产生子进程的终止方式:
- 传递参数并调用exit函数
- main函数中执行return并返回值
向exit函数传递的参数值和main函数的return语句返回的值都会传递给操作系统,而操作系统不会销毁子进程,直到把这些值传递给产生该子进程的父进程,处在这种状态下的进程就是僵尸进程。也就是说,将子进程变成僵尸进程的正是操作系统。既然如此,僵尸进程何时被销毁呢?其实之前已给出答案:当子进程将返回值传递给父进程的时候。那么,如何向父进程传递返回值呢?操作系统不会主动把这些值传递给父进程,只有父进程主动发起请求(函数调用)时,操作系统才会传递该值。换言之,如果父进程未主动要求获得子进程的结束状态值,操作系统将一直保存,并让子进程长时间处于僵尸进程状态。接下来的示例将创建僵尸进程:
#include <stdio.h> #include <unistd.h> int main(int argc, char *argv[]) { pid_t pid = fork(); if (pid == 0) // if Child Process { puts("Hi I'am a child process"); } else { printf("Child Process ID: %d \n", pid); //输出子进程ID,可以通过该值查看子进程状态(是否为僵尸进程) sleep(30); // Sleep 30 sec. 父进程暂停30秒,如果父进程终止,处于僵尸进程状态的子进程将同时销毁。因此,延缓父进程的执行以验证僵尸进程 } if (pid == 0) puts("End child process"); else puts("End parent process"); return 0; } # ./zombie Child Process ID: 5507 Hi I'am a child process End child process End parent process
程序开始运行,在打印出子进程的进程ID后,会停歇30秒,这个时候我们可以趁机看一下5507进程号所对应的进程状态。
# ps -ef | grep 5507 root 5507 5506 0 11:44 pts/32 00:00:00 [zombie] <defunct> root 5509 23062 0 11:45 pts/31 00:00:00 grep --color=auto 5507
可以看到,5507对应的进程号的状态为defunct,即为僵尸进程。经过30秒后,随着父进程的终止,子进程也将销毁
(2)销毁僵尸进程1:利用wait函数
如前所述,为了销毁子进程,父进程应主动请求获取子进程的返回值,接下来讨论下发起请求的具体方法,共有两种,其中之一就是调用wait函数:
#include <sys/wait.h>
pid_t wait(int *statloc);//成功时返回终止的子进程ID,失败时返回-1
调用次函数时如果已有子进程终止,那么子进程终止时传递的返回值(exit函数的参数值、main函数的return返回值)将保存到该函数的参数所指的内存空间。但函数参数指向的单元中还包含其他信息,因此需要通过下列宏进行分离
- WIFEXITED子进程正常终止时返回真(true)
- WEXITSTATUS返回子进程的返回值
也就是说,向wait函数传递变量status的地址时,调用wait函数后应编写如下代码 :
if (WIFEXITED(status)) { puts("Normal termination!"); printf("Child pass num: %d \n", WEXITSTATUS(status)); //返回值是多少 }
根据上述内容编写示例,此示例中不会再让子进程编程僵尸进程:
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/wait.h> int main(int argc, char *argv[]) { int status; pid_t pid = fork(); if (pid == 0) { return 3; } else { printf("Child PID: %d \n", pid); pid = fork(); if (pid == 0) { exit(7); } else { printf("Child PID: %d \n", pid); wait(&status); if (WIFEXITED(status)) printf("Child send one: %d \n", WEXITSTATUS(status)); wait(&status); if(WIFEXITED(status)) printf("Child send two: %d \n", WEXITSTATUS(status)); sleep(30); // Sleep 30 sec. } } return 0; }
- 第9、13行:第9行创建的子进程将在第13行通过main函数中的return语句终止
- 第18、21行:第18行中创建的子进程将在第21行通过调用exit函数终止
- 第26行:调用wait函数,之前终止的子进程相关信息将保存到status变量,同时相关子进程被完全销毁
- 第27、28行:第27行中通过WIFEXITED宏验证子进程是否正常终止,如果正常退出,则调用WEXITSTATUS宏输出子进程的返回值
- 第30~32行:因为之前创建了两个进程,所以再次调用wait函数和宏
- 第33行:为暂停父进程终止而插入的代码,此时可以查看子进程状态
#gcc wait.c -o wait
# ./wait
Child PID: 6862
Child PID: 6863
Child send one: 3
Child send two: 7
在系统中执行ps命令可以发现,并没有上一个示例中对应PID的进程。这是因为调用了wait函数,完全销毁了子进程,另外两个子进程终止时返回3和7传递给父进程。这就是通过调用wait函数消灭僵尸进程的方法,调用wait函数时,如果没有已终止的子进程,那么程序将阻塞直到有子进程终止,因此需谨慎调用该函数
(3)销毁僵尸进程2:使用waitpid函数
wait函数会引起程序的阻塞,还可以考虑调用waitpid函数,这是防止僵尸进程的第二种方法,也是防止阻塞的方法:
#include <sys/wait.h> pid_t waitpid(pid_t pid, int *statloc, int options);//成功时返回终止的子进程ID(或0),失败时返回-1
- pid:等待终止的目标子进程的ID,若传递-1,则与wait函数相同,可以等待任意子进程终止
- statloc:与wait函数的statloc具有相同意义
- options:传递头文件sys/wait.h中声明的常量WNOHANG,即使没有终止的子进程也不会进入阻塞状态,而是返回0并退出函数
下面介绍用上述函数的示例,调用waitpid函数,程序不会阻塞。
#include <stdio.h> #include <unistd.h> #include <sys/wait.h> int main(int argc, char *argv[]) { int status; pid_t pid = fork(); if (pid == 0) { sleep(15); //调用sleep函数推迟子进程的执行,这会导致程序延迟15秒 return 24; } else { while (!waitpid(-1, &status, WNOHANG)) //while循环调用waitpid函数,向第三个参数传递WNOHANG,因此,若之前没有终止的子进程将返回0 { sleep(3); puts("sleep 3sec."); } if (WIFEXITED(status)) printf("Child send %d \n", WEXITSTATUS(status)); } return 0; } # gcc waitpid.c -o waitpid # ./waitpid sleep 3sec. sleep 3sec. sleep 3sec. sleep 3sec. sleep 3sec. Child send 24
2.6、进程的内存结构
Linux操作系统采用虚拟内存管理技术,这样每个进程都有独立的地址空间,使得每个进程都有各自互不干涉的进程地址空间。该地址空间是大小为4GB的线性虚拟空间,用户所看到和接触到的都是该虚拟地址,无法看到实际的物理内存地址。利用这种虚拟地址不但能起到保护操作系统的效果(用户不能直接访问物理内存),而且更重要的是,用户程序可以使用比实际物理内存更大的地址空间。
4GB的进程地址空间会被分成两个部分:用户空间与内核空间。用户地址空间是从0到3GB(0xC0000000),内核地址空间占据3GB到4GB。用户进程在通常情况下只能访问用户空间的虚拟地址,不能访问内核空间的虚拟地址。只有用户进程使用系统调用(代表用户进程在内核态执行)时可以访问到内核空间。每当进程切换时,用户空间就会跟着变化;而内核空间由内核负责映射,它并不会跟着进程改变,是固定的。内核空间地址有自己对应的页表,用户进程各自有不同的页表。每个进程的用户空间都是完全独立、互不相干的。进程的虚拟内存地址空间如下图所示:
其中用户空间包括以下几个功能区域(通常称之为“段”):
只读段:包含程序代码(.init和.text)和只读数据(.rodata)。
数据段:存放的是全局变量和静态变量。其中可读可写数据段(.data)存放已初始化的全局变量和静态变量,BSS数据段(.bss)存放未初始化的全局变量和静态变量。
堆:由系统自动分配释放,存放函数的参数值、局部变量的值、返回地址等。
堆栈:存放动态分配的数据,一般由程序员动态分配和释放。若程序员不释放,程序结束时可能由操作系统回收。
共享库的内存映射区域:这是Linux动态链接器和其他共享库代码的映射区域。
由于在Linux系统中每一个进程都会有/proc文件系统下与之对应的一个目录(如将init进程的相关信息在/proc/1目录下的文件中描述),因此通过proc文件系统可以查看某个进程的地址空间的映射情况。例如,运行一个应用程序(示例中的可运行程序是在/home/david/project/目录下的test文件),如果它的进程号为13703,则输入“cat /proc/13703/maps”命令,可以查看该进程的内存映射情况,其结果如下:
$ cat /proc/13703/maps /* 只读段:代码段、只读数据段 */ 08048000-08049000 r-xp 00000000 08:01 876817 /home/david/project/test 08049000-0804a000 r--p 00000000 08:01 876817 /home/david/project/test /* 可读写数据段 */ 0804a000-0804b000 rw-p 00001000 08:01 876817 /home/david/project/test 0804b000-0804c000 rw-p 0804b000 00:00 0 08502000-08523000 rw-p 08502000 00:00 0 [heap] /* 堆 */ b7dec000-b7ded000 rw-p b7dec000 00:00 0 /* 动态共享库 */ b7ded000-b7f45000 r-xp 00000000 08:01 541691 /lib/tls/i686/cmov/libc-2.8.90.so b7f45000-b7f47000 r--p 00158000 08:01 541691 /lib/tls/i686/cmov/libc-2.8.90.so b7f47000-b7f48000 rw-p 0015a000 08:01 541691 /lib/tls/i686/cmov/libc-2.8.90.so b7f48000-b7f4b000 rw-p b7f48000 00:00 0 b7f57000-b7f5a000 rw-p b7f57000 00:00 0 /* 动态链接器 */ b7f5a000-b7f74000 r-xp 00000000 08:01 524307 /lib/ld-2.8.90.so b7f74000-b7f75000 r-xp b7f74000 00:00 0 [vdso] b7f75000-b7f76000 r--p 0001a000 08:01 524307 /lib/ld-2.8.90.so b7f76000-b7f77000 rw-p 0001b000 08:01 524307 /lib/ld-2.8.90.so bff61000-bff76000 rw-p bffeb000 00:00 0 [stack] /* 堆栈 */
2.7、Linux下的进程管理
(1)启动进程
A、手动启动
由用户输入命令直接启动进程,前台运行和后台运行
B、调度启动
系统根据用户事先的设定自行启动进程
at在指定时刻执行相关的进程
cron周期性执行相关进程
(2)调度进程
ps:查看系统的进程
top:动态显示系统中的进程
nice:按用户指定的优先级运行进程
renice:改变正在运行进程的优先级
kill:结束进程(包括后台进程)
bg:将挂起的进程在后台执行
fg:把后台运行的进程放到前台运行
3、线程(轻量级进程)
前面已经提到,进程是系统中程序执行和资源分配的基本单位。每个进程都拥有自己的数据段、代码段和堆栈段,这就造成了进程在进行切换时操作系统的开销比较大。为了提高效率,操作系统又引入了另外一个概念-----线程,也称为轻量级进程。线程可以对进程的内存空间和资源进行访问,并与同一进程中的其它线程共享。因此,线程的上下文切换的开销比进小的多。
系统为每个用户进程创建一个task_struct来描述该进程,该结构体中包含了一个指针指向该进程的虚拟地址空间映射表。实际上task_struct和地址空间映射表一起用来表示一个进程。
由于进程的地址空间是私有的,因此在进程间上下文切换时,系统开销比较大。为了提高系统的性能,许多操作系统规范里引入了轻量级进程的概念,也被称为线程。在同一个进程中创建的线程共享该进程的地址空间。Linux中同样用task_struct来描述一个线程,线程和进程都参与统一的调度。
通常线程指的是共享相同地址空间的多个任务,使用多线程的好处是:大大提高了任务切换的效率,避免了额外的TLB&cache的刷新。
为了进一步减少处理器的空转时间,支持多处理器以及减少上下文切换开销,进程在演化中出现了另一个概念-----线程。它是进程内独立的一条运行路线,是内核调度的最小单元,也被称为轻量级进程。线程由于具有高效性和可操作性,在嵌入式系统开发中运用的非常广泛,希望读者能够很好地掌握。
这里要讲的线程相关操作都是用户空间中的线程的操作。在Linux中,一般pthread线程库是一套通用的线程库,是由POSIX提出的,因此具有很好的可移植性。
pthread线程库是第三方库,不是标准的C库。代码中有第三方库是,在编译时要在后面加上库名(-lpthread或-pthread),以显示连接该库,如gcc main.c -pthread。
线程通过全局变量和堆区来进行通信。
一个进程可以拥有多个线程,其中每个线程共享该进程所拥有的的资源。要注意的是,由于线程共享了进程的资源和地址空间,因此,任何线程对系统资源的操作都会给其它线程带来影响。由此可知,多线程中的同步时非常重要的问题。在多线程系统中,进程与线程的关系如下图所示:
一个进程中的多个线程共享的资源有:可执行的命令、静态数据、进程中打开的文件描述符、信号处理函数、当前工作目录、用户ID、用户组ID。
每个线程私有的资源有:线程ID(TID)、PC(程序计数器)和相关寄存器、堆栈(局部变量、返回地址)、错误号、信号掩码和优先级、执行状态和属性
进程拥有自己的代码段,数据段,堆栈段,因此在进程切换时操作系统开销大,为了解决这个问题,因此引入了线程这一概念,线程也称为轻量级进程。线程可以对进程的空间和资源进行访问,也可以与同一进程中的其他线程共享进程的资源,由于共享进程的资源,也引出了共享资源的同步问题。