一个进程就是一个正在执行程序的实例,包括程序计数器、寄存器和变量的当前值。
进程类似于人类,它们被产生,有生命周期,有或多或少的子进程,最终都走向死亡。(当然了,进程不分性别~)
注意:如果一个进程运行了两遍,则算作两个进程。程序本身并不是进程,进程是处于执行期的程序以及相关资源的总称。完全有可能存在两个或多个不同的进程执行的是同一个程序。
为了实现进程模型,操作系统通常维护一个很大的数据结构,称为进程表或进程控制块(PCB),进程在被创建时就被动态地分配了程序计数器、进程堆栈和一组寄存器。 PCB包括了这些信息。下表展示了一个典型系统中的关键字段:
进程管理 | 存储管理 | 文件管理 |
---|---|---|
寄存器 | 正文段指针 | 根目录 |
程序计数器 | 数据段指针 | 工作目录 |
堆栈指针 | 堆栈段指针 | 文件描述符 |
进程状态 | …… | 用户ID |
优先级 | 组ID | |
进程ID | …… | |
父进程 | ||
…… |
在某一瞬间,CPU(单核)只能运行一个进程,但在1秒钟内CPU可能轮流处理多个进程,使每个进程各运行几十到几百毫秒,产生“并行”的错觉(即伪并行)。
真正的并行需要多核CPU系统。
进程的创建和撤销、进程的状态、进程的组织以及进程间通信的基本概念需要不断巩固,这里由于篇幅限制不再赘述
现代操作系统支持多线程应用的支持,线程是在进程中活动的对象,每个线程都拥有独立的(不同于进程的)程序计数器、堆栈和一组寄存器。
线程可以共享的内容 | 线程本身的内容 |
---|---|
地址空间 | 程序计数器 |
全局变量 | 寄存器 |
打开文件 | 堆栈 |
子进程 | |
信号处理 | |
账户信息 |
线程的引入是为了使并行的实体有共享同一地址空间的能力,多进程模型无法做到这一点,因为每个进程有不同的地址空间,而不同的线程(同一进程中)都能够共享同一地址,这对于特定的应用是一项必需的能力。
引入线程还有其他的原因:线程比进程更加轻量级。它们比进程更快地创建和撤销。通常创建一个线程比创建一个进程快10~100倍。
实现线程模型通常有两种方式,在用户空间和内核中,两种方法互有利弊。用户空间实现线程可以使不支持多线程的操作系统来运行线程,因为内核对线程一无所知,并按照正常方式来管理它看到的进程。
这里模拟一下用户空间实现线程,实现线程之前,需要先提一下 POSIX线程 的概念:为了方便线程的移植,IEEE 1003.1c中定义的线程标准,标准规定了一个叫做pthread的线程包,包含了超过60个函数调用。
这里直接使用标准来实现用户空间线程。
#include
#include
#include
#define NUMBERS_OF_THREADS 10
void *print_hello(void *tid)
{
printf("hello thread %d\n",tid);
pthread_exit(NULL);
}
int main()
{
pthread_t threads[NUMBERS_OF_THREADS];
int status;
for(int i = 0; i < NUMBERS_OF_THREADS; ++i)
{
printf("creating thread %d",i);
status = pthread_creat(&threads[i], NULL, print_hello, (void *)i);
}
exit(NULL);
}
当有线程等待进程中另一个线程时,它调用一个过程来检查是否需要阻塞,如果是,它在线程表中(线程所在进程来管理)保存自己的寄存器并检查就绪线程(与进程很像~)
保存线程状态的过程和调度过程只是本地过程(没有陷入内核),不需要内核调用、上下文切换,也不用刷新高速缓存,速度比内核级线程快了一个数量级。
当然用户级线程也有很大的问题,由于共享同一内存,当有一个线程阻塞了系统调用,操作系统并不认识线程,它会停止整个进程。缺页中断也会造成类似的问题。
(内核中线程的具体实现以后再讨论)内核支持线程时,线程表不再存放于进程分配的内存中,而是存放在内核中,当创建一个线程或撤销一个线程都会进行系统调用。
注意,前面提到内核级线程在创建和撤销时的开销是很可观的
所以在撤销线程时,内核通常仅仅修改其运行状态标志,而不破坏线程的数据结构。
创建线程时则寻找某个旧的线程修改并激活它
这样做可以节省一部分开销。这些操作对用户级线程没有必要,因为其线程管理的代价很小。
在Linux中,进程通常被称为任务(task)或线程(thread)。
内核把进程的列表存放在叫做任务队列(task list)的双向循环链表中,也称进程链表。
该链表中的每一项都是类型为task_struct的结构,即进程描述符。 task_struct在
Linux通过slab分配器分配task_struct结构。在Linux 2.6版本以前的内核中,每个进程的task_struct存放在内核栈的栈尾,如图。
目钱由于使用了slab分配器,所以只需在栈底存放一个新的结构thread_info,叫做线程描述符。
Linux把thread_info结构和内核堆栈结构联合在一起存放于一个8K(两个页框)大小的存储区,如图所示:
C语言使用联合体来描述两个结构体捆绑在一起的情形:
union thread_union
{
struct thread_info tdif;
unsigned long stack[2048];
}
thread_info结构在
栈顶指针由esp寄存器存放,一旦数据写入堆栈,esp就递减(栈向下生长)。
在8K的thread_info结构中,只要屏蔽掉esp的低13位有效位就可以得到thread_info的基地址。代码如下:
movl $-8192, %eas
andl %esp, %eas
进程最常用的是进程描述符的地址而不是thread_info结构的地址。为了获取当前运行进程的描述符指针,内核要调用current宏。task字段在thread_info中的偏移量为0,所以调用current宏本质上等于current_thread_info() -> task
需要说明的是thread_info结构占52个字节,所以内核堆栈只能增长到 8 * 1024 - 52 = 8140个字节。
类UNIX系统允许用户使用进程标识符PID来标识进程,内核通过一个唯一的进程标识或PID来标识每一个进程。
PID有上限,当内核使用的PID到达上限时,必须循环使用未使用过的PID。32位系统PID最大值设置为32768,64位设置为4194303。
循环使用PID需要用到pidmap_array位图来管理分配信息,一个PID可以用作于一个线程组,一个线程组中的所有线程和该线程的领头线程PID相同,领头线程的PID存放在task_struct中的tgid字段中。
getpid()返回的是tgid而不是pid
进程描述符中的state字段描述了当前进程所处的状态。(请自行对应传统OS课程的进程状态:运行态、就绪态、阻塞态)
还有两个状态存放在exit_state字段。
set_task_state(task, state); //将任务task状态设置为state
set_current_state(state); //将当前任务状态设置为state
set_task_state(current, state);和set_current_state(current);效果一样。
程序创建的进程具有父子关系,如果一个进程创建多个子进程时,则子进程之间具有兄弟关系。进程间的关系存放在进程描述符中。
Linux所有的进程都是PID为1的init进程的后代。
内核在系统启动的最后阶段启动init进程,该进程读取系统的初始化脚本(initscript)并执行其他的相关程序。
其中表示亲属关系的字段有
字段名 | 描述 |
---|---|
real_parent | 指向了创建P的进程描述符 |
parent | 指向P的当前父进程 |
children | 链表的头部,链表中的元素都为P创建的子进程 |
sibling | 指向兄弟进程链表的下一个元素,它们的父进程都是P |
非亲属关系的进程描述符字段
字段名 | 描述 |
---|---|
group_leader | P所在进程组的领头进程的描述符指针 |
sigmal -> pgrp | P所在进程组的领头进程的PID |
tgid | P所在线程组的领头进程的PID |
signal -> session | P的登录会话领头进程的PID |
ptrace_list | 指向所跟踪进程其实际父进程链表的前一个和下一个元素 |
对于当前进程,可以通过下面的代码获得其父进程的进程描述符:
struct task_struct *my_parent = current -> parent;
访问子进程
struct task_struct *task;
struct list_head *list;
list_for_each(list, ¤t -> children)
{
task = list_entry(list, struct task_struct, sibling);
}
init进程的进程描述符是作为init_task静态分配的。
下面这段代码演示了进程之间的关系:
struct task_struct *task;
for(task = current; task != &init_task; task = task -> parent)
注意,在一个拥有大量进程的系统中通过重复来遍历所有的进程代价是很大的,为了加速查找并检查pid域,引入了pidhash散列表,它由PIDHASH_SZ个元素组成,表项包含进程描述符指针。
首先用pid_hashfn宏把PID转换成表的索引:
#define pid_hashfn(x) \
(((x) >> 8) ^ (x)) & (PIDHASH_SZ - 1))
散列函数不能保证PID表与索引一一对应,会发生两个甚至更多PID散列后得到相同的表索引的情况,这称为 冲突(colliding)
传统数据结构课程描述解决冲突的方法有开放地址法、链地址法、再哈希法等,Linux利用链地址法来处理冲突:使用来自进程描述符的 pidhash_next和 pidhash_pprev来实现。
调用 hash_pid()和unhash_pid() 函数可以在pidhash表中增加和删除一个进程。
find_task_by_pid() 函数查找散列表并返回PID进程所在进程描述符的指针。