【Linux内核系列】 进程管理

目录

一、Linux中的进程

1.1 进程的状态

1.2 进程ID

2.3 进程树关系

2.4 进程调度优先级

2.5 进程地址空间

二、进程的上下文切换

三、进程的通讯方式

3.1 管道

3.2 消息队列

3.3 共享内存

3.4 信号量

3.5 信号

3.6 Socket

一、Linux中的进程

在学习进程管理之前,我们先来学习什么是进程。

在 Linux 中,是用一个 task_struct 来表示 Linux 进程的,我们来看看 task_struct 具体的定义:

//file:include/linux/sched.h
struct task_struct {
 //2.1 进程状态 
 volatile long state;

 //2.2 进程线程的pid
 pid_t pid;
 pid_t tgid;

 //2.3 进程树关系:父进程、子进程、兄弟进程
 struct task_struct __rcu *parent;
 struct list_head children; 
 struct list_head sibling;
 struct task_struct *group_leader; 

 //2.4 进程调度优先级
 int prio, static_prio, normal_prio;
 unsigned int rt_priority;

 //2.5 进程地址空间
 struct mm_struct *mm, *active_mm;

 //2.6 进程文件系统信息(当前目录等)
 struct fs_struct *fs;

 //2.7 进程打开的文件信息
 struct files_struct *files;

 //2.8 namespaces 
 struct nsproxy *nsproxy;
}

1.1 进程的状态

进程共有五种状态,这五种状态用 state 来表示,分别是:

  • 运行状态(Running):该时刻进程占用 CPU;
  • 就绪状态(Ready):可运行,由于其他进程处于运行状态而暂时停止运行;
  • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;
  • 创建状态(new):进程正在被创建时的状态;
  • 结束状态(Exit):进程正在从系统中消失时的状态;

一个任务(进程或线程)刚创建出来的时候是 Ready 就绪状态,等待调度器的调度。调度器执行 schedule 后,任务获得 CPU 后进入 执行进行运行。当需要等待某个事件的时候,例如阻塞式 read 某个 socket 上的数据,但是数据还没有到达的时候,任务进入 Blocked 状态,任务被阻塞掉。

当等待的事件到达以后,例如 socket 上的数据到达了。内核在收到数据后会查看 socket 上阻塞的等待任务队列,然后将之唤醒,使得任务重新进入 Ready 就绪状态。任务如此往复地在各个状态之间循环,直到退出。

1.2 进程ID

我们知道,每一个进程都有一个进程 id 的概念。在 task_struct 中有两个相关的字段,分别是 pid 和 tgid。

struct task_struct {
 ......
 pid_t pid;
 pid_t tgid;
}

其中 pid 是 Linux 为了标识每一个进程而分配给它们的唯一号码,称做进程 ID 号,简称 PID。对于没有创建线程的进程(只包含一个主线程)来说,这个 pid 就是进程的 PID,tgid 和 pid 是相同的。

2.3 进程树关系

在 Linux 下所有的进程都是通过一棵树来管理的。在操作系统启动的时候,会创建 init 进程,接下来所有的进程都是由这个进程直接或者间接创建的的。通过 pstree 命令可以查看你当前服务器上的进程树信息。

2.4 进程调度优先级

在 task_struct 中有几个字段是表示进程优先级的,在进程调度的时候会根据这几个字段来决定优先让哪个任务(进程或线程)开始执行。

  • static_prio: 用来保存静态优先级,可以调用 nice 系统直接来修改取值范围为 100~139

  • rt_priority: 用来保存实时优先级,取值范围为 0~99

  • prio: 用来保存动态优先级

  • normal_prio: 它的值取决于静态优先级和调度策略

2.5 进程地址空间

对于用户进程来讲,内存描述符 mm_struct( mm 代表的是 memory descriptor)是非常核心的数据结构。整个进程的虚拟地址空间部分都是由它来表示的。

进程在运行的时候,在用户态其所需要的代码,全局变量数据,以及 mmap 内存映射等全部都是通过 mm_struct 来进行内存查找和寻址的。

struct mm_struct {
 struct vm_area_struct * mmap;  /* list of VMAs */
 struct rb_root mm_rb;

 unsigned long mmap_base;  /* base of mmap area */
 unsigned long task_size;  /* size of task vm space */
 unsigned long start_code, end_code, start_data, end_data;
 unsigned long start_brk, brk, start_stack;
 unsigned long arg_start, arg_end, env_start, env_end;
}

其中 start_code、end_code 分别指向代码段的开始与结尾、start_data 和 end_data 共同决定数据段的区域、start_brk 和 brk 中间是堆内存的位置、start_stack 是用户态堆栈的起始地址。

在内核内存区域,可以通过直接计算得出物理内存地址,并不需要复杂的页表计算。而且最重要的是所有内核进程、以及用户进程的内核态,这部分内存都是共享的。

另外要注意的是,对于内核线程来说,是没有用户态的虚拟地址空间的。所以内核线程的 mm 的值是 null。

二、进程的上下文切换

各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换

进程的切换,实质上就是被中断运行进程与待运行进程的上下文切换。从主观上来理解。只分为两步:
1.切换新的页表,然后使用新的虚拟地址空间
2.切换内核栈,加入新的内容(PCB控制块,资源相关),硬件上下文切换

三、进程的通讯方式

进程的通讯方式可以分为两大类:消息传递模型和共享内存模型。管道,消息队列,信号,信号量,socket这些都属于消息传递模型,而共享内存属于共享内存模型。

3.1 管道

管道是最简单的进程间的通讯方式,而且管道传输数据是单向的,如果想相互通信,我们需要创建两个管道才行。

管道分为匿名管道和有名管道。

  • 匿名管道:通信范围是存在父子关系的进程。因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的,同时用完即毁。
  • 有名管道:可以在不相关的进程间也能相互通信。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。

不管是匿名管道还是命名管道,进程写入的数据都是缓存在内核中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。

3.2 消息队列

管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据。

对于这个问题,消息队列的通信模式就可以解决。比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。

再来,消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。

消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。

但消息队列也有明显的缺点,一是通信不及时,二是附件也有大小限制。

3.3 共享内存

消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。那共享内存的方式,就很好的解决了这一问题。

现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。

共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。

3.4 信号量

用了共享内存通信方式,带来新的问题,那就是如果多个进程同时修改同一个共享内存,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。

为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制。

信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据

信号量表示资源的数量,控制信号量的方式有两种原子操作:

  • 一个是 P 操作,这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
  • 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;

P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。

3.5 信号

上面说的进程间通信,都是常规状态下的工作模式。对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。

在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。我们可以通过 kill -l 命令,查看所有的信号。

信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。

1.执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思。

2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。

3.忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程

3.6 Socket

前面提到的管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,那要想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。

对于Socket的使用,在前面的文章中已经讲过了,大家可以去看一下:http://t.csdn.cn/jLuHq

猜你喜欢

转载自blog.csdn.net/weixin_52967653/article/details/127520311