既然进程可以并发执行,那么他们是在程序运行是什么状态呢?不同的系统可能会有不同的状态,以下为大多数情况:
进程一般存在三种情况:
(1)就绪状态:我已经准备好,给我处理器,我就可以执行,这时的进程状态就是就绪状态;
(2)执行状态:我已经获得资源,并且正在工作中,这时的进程状态就是执行状态;
(3)阻塞状态:我刚刚正在工作,突然“断电”了,我被迫停止,这时的进程状态就是阻塞状态。
但是,不是说我处于一种状态就会不变,正在执行的总会做完,被迫停止的也可以重新开始。
关于三种状态,举个栗子:
在食堂买饭,有人已经买上饭菜了坐了正在吃,有人正在排队买菜,有人买好了但是没有座位,正在等待。
三、进程的控制
进程控制主要是负责进程的创建与撤销,进程状态之间的切换以及进程之间的通信等。当然这也是系统的基本功能,在内核中的相应程序中完成。
但是什么是操作系统内核?
操作系统内核是指扩充计算机硬件的第一层,广泛采用层次式结构,通常将一些与硬件密切相关的模块,比如中断处理程序,设备驱动程序,存储器管理等安排在紧靠硬件的软件层,并且让他们常住在内存中,施以保护。内核在实现实现其基本功能时基本常采用原语操作。
什么是原语?
可以简单的看作是命令。主要介绍一下进程控制语言,主要包括进程的创建与撤销、阻塞与唤醒、刮起与激活等六个原语。
(1)进程创建原语:进程通过调用进程创建原语来创建一个子进程,步骤为:申请空闲的PCB(进程控制模块),为子进程获得新的标识-->为子进程分配诸如内存空间的资源-->初始化进程控制模块-->将新进程插入到PCB的就绪队列中。创建ok。
(2)进程撤销原语:撤销原语在撤销进程时,连同该进程的子孙进程一同撤销。步骤为:根据被撤销的进程的标识符从PCB检索表中找到该进程的PCB,并获得该进程的状态-->若进程处于执行状态,立即终止其执行,并且将其逻辑值重置;若进程不是执行状态,直接将其从状态队列中删去-->递归的处理该进程的子孙进程-->撤销进程时,将所有资源归位,注销其资源描述清单-->释放该进程的PCB。撤销ok,但是如果其逻辑值为真,则会转入进程调度程序。
(3)进程阻塞原语:当进程请求某个事件尚未出现时,进行步骤:终止调用者自身的执行-->该进程调用进程阻塞原语使其从执行状态变为阻塞状态-->把调用者进程的PCB插入到相应的阻塞队列-->然后转入进程调度程序。
(4)进程唤醒原语:执行的进程释放某资源之后,调用进程唤醒原语将因等待该资源而阻塞的进程唤醒成就绪状态。进行步骤:找出相应被唤醒的进程的内部标识-->把该标识从阻塞队列中移去-->重设该状态为就绪-->将该进程插入到就绪队列中去。
Linux信息项目(The Linux Information)把进程定义为“程序的一个执行(即,运行)实例”。所以,要定义进程我们先要定义什么是程序。再次根据Linux信息项目里的定义,“程序是内存里的一个可执行文件。”
所以,我们知道进程是正在运行的程序的一部分。这是否意味着进程一定是在运行中的?不一定。
进程状态
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在Linux内核里,进程有时候也叫做任务)。
下面的状态在 fs/proc/array.c 文件里定义:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char * const task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"t (tracing stop)", /* 8 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};
|
运行状态(running)并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。睡眠状态(sleeping)意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
例如,可以用下面的方法来停止或继续运行进程:
1
2
|
kill -SIGSTOP <pid>
kill -SIGCONT <pid>
|
可以使用gdb终止进程来实现跟踪终止状态。如果我没有记错的话,这个状态和终止状态基本上是一样的。
死亡状态是内核运行 kernel/exit.c 里的 do_exit() 函数返回的状态。这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
僵死状态(Zombies)是一个比较特殊的状态。有些人认为这个状态是在父进程死亡而子进程存活时产生的。实际上不是这样的。父进程可能已经死了但子进程依然存活着,那个子进程的父进程将会成为init进程,pid 1。当进程退出并且父进程(使用wait()系统调用)没有读取到子进程退出的返回代码时就会产生僵死进程。僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
这里有一个创建维持30秒的僵死进程例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
#include <stdio.h>
#include <stdlib.h>
/*
* A program to create a 30s zombie
* The parent spawns a process that isn't reaped until after 30s.
* The process will be reaped after the parent is done with sleep.
*/
int main(int argc, char **argv[])
{
int id = fork();
if ( id > 0 ) {
printf("Parent is sleeping..n");
sleep(30);
}
if ( id == 0 )
printf("Child process is done.n");
exit(EXIT_SUCCESS);
}
|
Linux进程状态是一篇非常棒的文章,它使用代码例子来讲述进程状态并使用 ptrace 来控制它。
进程包含了什么信息?
我简要地提过进程表,我将会在这解释什么是进程表。进程表是Linux内核的一种数据结构,它会被装载到RAM里并且包含着进程的信息。
每个进程都把它的信息放在 task_struct 这个数据结构里,task_struct 包含了这些内容:
- 状态(任务状态,退出代码,退出信号。。。)
- 优先级
- 进程id(PID)
- 父进程id(PPID)
- 子进程
- 使用情况(cpu时间,打开的文件。。。)
- 跟踪信息
- 调度信息
- 内存管理信息
保存进程信息的数据结构叫做 task_struct,并且可以在 include/linux/sched.h 里找到它。所有运行在系统里的进程都以 task_struct 链表的形式存在内核里。
进程的信息可以通过 /proc 系统文件夹查看。要获取PID为400的进程信息,你需要查看 /proc/400 这个文件夹。大多数进程信息同样可以使用top和ps这些用户级工具来获取。
进程执行
当进程执行时,它会被装载进虚拟内存,为程序变量分配空间,并把相关信息添到task_struct里。
进程内存布局分为四个不同的段:
- 文本段,包含程序的源指令。
- 数据段,包含了静态变量。
- 堆,动态内存分区区域。
- 栈,动态增长与收缩的段,保存本地变量。
这里有两种创建进程的方法,fork()和execve()。它们都是系统调用,但它们的运行方式有点不同。
要创建一个子进程可以执行fork()系统调用。然后子进程会得到父进程中数据段,栈段和堆区域的一份拷贝。子进程独立可以修改这些内存段。但是文本段是父进程和子进程共享的内存段,不能被子进程修改。
如果使用execve()创建一个新进程。这个系统调用会销毁所有的内存段去重新创建一个新的内存段。然而,execve()需要一个可执行文件或者脚本作为参数,这和fork()有所不同。
注意,execve()和fork()创建的进程都是运行进程的子进程。
进程执行还有很多其他的内容,比如进程调度,权限许可,资源限制,库链接,内存映射… 然而这篇文章由于篇幅限制不可能都讲述,以后访问可能会加上
进程间通信(IPC)
为了进程间的通信,存在两个解决方法,共享内存,消息传递。
在共享内存的方案里,为了几个进程间能够通信创建了一个共享的区域。这个区域能被多个进程同时访问。这种方法通常在使用线程时使用。这是实现IPC最快的形式,因为这种形式只涉及到内存的读写。 但是,这需要进程在访问共享内存时受到的限制和访问内核实现的其他进程内存一样。
共享内存段的使用情况可以使用ipcs -m命令查看。
实现一个共享内存的服务器程序,代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
#include <stdlib.h>
#include <stdio.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#define SEGMENT_SIZE 64
int main(int argc, char **argv[])
{
int shmid;
char *shmaddr;
/* Create or get the shared memory segment */
if ((shmid = shmget(555, SEGMENT_SIZE, 0644 | IPC_CREAT)) == -1) {
printf("Error: Could not get memory segmentn");
exit(EXIT_FAILURE);
}
/* Attach to the shared memory segment */
if ((shmaddr = shmat(shmid, NULL, 0)) == (char *) -1) {
printf("Error: Could not attach to memory segmentn");
exit(EXIT_FAILURE);
}
/* Write a character to the shared memory segment */
*shmaddr = 'a';
/* Detach the shared memory segment */
if (shmdt(shmaddr) == -1) {
printf("Error: Could not close memory segmentn");
exit(EXIT_FAILURE);
}
exit(EXIT_SUCCESS);
}
|
通过把 *shmaddr = ‘a’; 替换为 printf(“Segment: %sn”, shmaddr) ,你将会得到一个客户端程序并且能够读取共享内存段的数据。
运行 ipcs -m 将会输出服务共享内存段的信息:
1
2
3
4
5
|
anton@shell:~$ ipcs -m
------ Shared Memory Segments --------
key shmid owner perms bytes nattch status
0x0000022b 0 anton 644 64 0
|
共享内存段可以使用 ipcrm 命令移除。要了解更多的共享内存实现IPC,可以阅读Beej的共享内存段教程。
其他实现IPC的方法有文件,信号,套接字,消息队列,管道,信号灯和消息传递。这些方法我不可能全部都深入讲解,但我觉得信号和管道的方法我需要提供一些有趣的例子。
信号
介绍进程状态时,我们已经看了一个使用kill命令的信号示例。信号是把事件或者异常的发生通知进程的软件中断。
每个信号都有一个整型标识,但通常使用 SIGXXX 来描述信号,例如 SIGSTOP 或者 SIGCONT 。内核使用信号来通知进程事件的发生,进程也可以使用kill()系统调用发送信号给进程。接收信号的进程可以忽略信号,被杀死,或者被挂起。可以使用信号处理器来处理信号并且在信号出现时任意处理信号。SIGKILL 这个特殊的信号不能被捕获(处理器处理),要杀死一个挂起的进程时可以使用这个信号。不要把 SIGKILL 和 SIGTERM 混淆了,当使用 Ctrl+C 或者 kill <PID> 杀死进程时默认会发送 SIGKILL 信号。 SIGTERM 不会强制杀死进程并且它可以被捕获,使用 SIGTERM 的进程通常可以被清理。
管道
管道用来把一个进程的输出连接到另外一个进程的输入。这是实现IPC最古老的方法之一。普通管道是单向通信的, 它有一个单向流。可以使用pipe() 创建一个管道,管道和Linux的其他对象一样,都被看成文件对象。
通其他文件一样,read()和write()操作都适用于管道。
命名管道是普通管道的增强版,它是双向通信的并且可以实现管道的多进程读写。这都是普通管道不能实现的。无论有没有进程对命名管道进行读写,它都会实际存在。命名管道在文件系统里以特殊设备文件存在。在GNU/Linux里,命名管道也被称为FIFOs(先进先出,First In First Out)。
这里有一个创建命名管道的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
#include <stdlib.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
int main(int argc, char **argv[])
{
if (mknod("myfifo", S_IFIFO|0666, 0) == -1) {
printf("Failed to mknodn");
exit(EXIT_FAILURE);
}
exit(EXIT_SUCCESS);
}
|
在运行目录里,我们会看到myfifo文件。它的信息和下面的类似:
1
|
prw-rw-r-- 1 anton anton 0 Dec 16 16:14 myfifo
|
以上就是进程的基本介绍。写得越多我就越意识到进程有太多东西要讲了。从哪里开始讲进程和把不需要覆盖的知识划分出来,这是个很艰难的决定。共享内存段是我没有很好地规划好的一部分。回看进程间通信那部分是很有趣的。此外,因为有大量诸如Linux编程接口和操作系统概念的好资源,使我们更容易回归概念思考。