系统相关
首页 > 系统相关> > 趣谈linux操作系统--Linux进程管理笔记

趣谈linux操作系统--Linux进程管理笔记

作者:互联网

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

#define TASK_KILLABLE (TASK_WAKEKILL | TASK_UNINTERRUPTIBLE)
 **task_struct** 
//是否在运行队列上
int on_rq;
//优先级int prio;int static_prio;
int normal_prio;
unsigned int rt_priority;
//调度器类
const struct sched_class *sched_class;
//调度实体
struct sched_entity se;
struct sched_rt_entity rt;
struct sched_dl_entity dl;
//调度策略
unsigned int policy;
//可以使用哪些
CPUint nr_cpus_allowed;
cpumask_t cpus_allowed;
struct sched_info sched_info;

进程管理 task_struct 的结构图

在这里插入图片描述- 内核中进程, 线程统一为任务, 由 taks_struct 表示

进程亲缘关系

struct task_struct __rcu *real_parent; /* real parent process */
struct task_struct __rcu *parent; /* recipient of SIGCHLD, wait4() reports */
struct list_head children;      /* list of my children */
struct list_head sibling;       /* linkage in my parent's children list */

parent 指向其父进程。当它终止时,必须向它的父进程发送信号。
children 表示链表的头部。链表中的所有元素都是它的子进程。
sibling 用于把当前进程插入到兄弟链表中。
在这里插入图片描述

在这里插入图片描述
运行统计信息, 包含用户/内核态运行时间; 上/下文切换次数; 启动时间等;

函数调用

原本不理解为什么调用函数时先压被调用函数参数,最后压返回地址,那么被调用函数怎么越过返回地址得到所需的参数。现在结合这个图就清晰了。
EBP的存在是为了和ESP搭配,限定当前函数堆栈范围,当调用新函数的时候自然要开新的栈。怎么开新栈呢?就是让EBP等于ESP。为了保证被调用函数返回之后能回忆起来调用函数的栈桢范围,需要在设置被调用函数的EBP=ESP之前,将EBP压入栈。也就是上图中最底下的绿色块。 在堆栈中两个函数交接的结构是固定的,即图中从参数N到最底下绿色块EBP这个结构是固定的。所以如果被调用函数想要访问第一个参数的话,是通过EBP+8来访问,访问第二个参数通过EBP+12,以此类推。如果允许的话,还可以通过EBP+4来访问函数返回地址。 缓冲区溢出攻击就是通过往堆栈中写入超过为其所分配空间的元素,导致覆盖了函数返回地址,然后当函数执行完了自动将esp减到ebp位置的时候,弹出一个被修改的ebp,再弹出一个被修改的返回地址,导致执行了意料之外的程序。
在这里插入图片描述在这里插入图片描述

内核栈是一个非常特殊的结构

在这里插入图片描述
在用户态,应用程序进行了至少一次函数调用。
32 位和 64 的传递参数的方式稍有不同,32 位的就是用函数栈,64 位的前 6 个参数用寄存器,其他的用函数栈。
在内核态,32 位和 64 位都使用内核栈,格式也稍有不同,主要集中在 pt_regs 结构上。
在内核态,32 位和 64 位的内核栈和 task_struct 的关联关系不同。32 位主要靠 thread_info,64 位主要靠 Per-CPU 变量。
在这里插入图片描述
用户态/内核态切换执行如何串起来

调度策略与调度类

task_struct 中,有一个成员变量,我们叫调度策略

unsigned int policy;

有以下几个定义:

#define SCHED_NORMAL    0
#define SCHED_FIFO    1
#define SCHED_RR    2
#define SCHED_BATCH    3
#define SCHED_IDLE    5
#define SCHED_DEADLINE    6

实时进程的调度策略

SCHED_FIFO、SCHED_RR、SCHED_DEADLINE 是实时进程的调度策略
1.SCHED_FIFO 先来先服务 高优先级的进程可以抢占低优先级的进程,而相同优先级的进程,我们遵循先来先得。
2.SCHED_RR 轮流调度算法 采用时间片,相同优先级的任务当用完时间片会被放到队列尾部,以保证公平性,而高优先级的任务也是可以抢占低优先级的任务。
3. SCHED_DEADLINE 按照任务的 deadline 进行调度的。当产生一个调度点的时候,DL 调度器总是选择其 deadline 距离当前时间点最近的那个任务,并调度它执行。

普通调度策略

int prio, static_prio, normal_prio;
unsigned int rt_priority;

实时进程,优先级的范围是 0~99;
对于普通进程,优先级的范围是 100~139。
数值越小,优先级越高。
从这里可以看出,所有的实时进程都比普通进程优先级要高

调度策略的执行逻

const struct sched_class *sched_class; // task_struct 里面,还有这样的成员变量:

sched_class 有几种实现:

完全公平调度算法CFS

首先,需要记录下进程的运行时间。CPU 会提供一个时钟,过一段时间就触发一个时钟中断,我们叫 Tick。CFS 会为每一个进程安排一个虚拟运行时间 vruntime。如果一个进程在运行,随着时间的增长,也就是一个个 tick 的到来,进程的 vruntime 将不断增大。没有得到执行的进程 vruntime 不变。

虚拟运行时间 vruntime += 实际运行时间 delta_exec * NICE_0_LOAD/ 权重

当选取下一个运行进程的时候,按照最小的 vruntime 来。
解释:同样的实际运行时间,给高权重的算少了,低权重的算多了,但是当选取下一个运行进程的时候,还是按照最小的 vruntime 来的,这样高权重的获得的实际运行时间自然就多了。这就相当于给一个体重 (权重)200 斤的胖子吃两个馒头,和给一个体重 100 斤的瘦子吃一个馒头,然后说,你们两个吃的是一样多。这样虽然总体胖子比瘦子多吃了一倍,但是还是公平的。

调度队列与调度实体
struct sched_entity {
  struct load_weight    load;
  struct rb_node      run_node;
  struct list_head    group_node;
  unsigned int      on_rq;
  u64        exec_start;
  u64        sum_exec_runtime;
  u64        vruntime;
  u64        prev_sum_exec_runtime;
  u64        nr_migrations;
  struct sched_statistics    statistics;
......
};

在这里插入图片描述vruntime 最小的在树的左侧,vruntime 最多的在树的右侧。 CFS 调度策略会选择红黑树最左边的叶子节点作为下一个将获得 CPU 的任务。

CPU 任务队列

每个 CPU 都有自己的 struct rq 结构,其用于描述在此 CPU 上所运行的所有进程
包括:实时进程队列 rt_rq 和一个 CFS 运行队列 cfs_rq

//CPU  struct rq 结构
struct rq {
  /* runqueue lock: */
  raw_spinlock_t lock;
  unsigned int nr_running;
  unsigned long cpu_load[CPU_LOAD_IDX_MAX];
......
  struct load_weight load;
  unsigned long nr_load_updates;
  u64 nr_switches;


  struct cfs_rq cfs;
  struct rt_rq rt;
  struct dl_rq dl;
......
  struct task_struct *curr, *idle, *stop;
......
};

/* CFS-related fields in a runqueue */
struct cfs_rq {
  struct load_weight load;
  unsigned int nr_running, h_nr_running;


  u64 exec_clock;
  u64 min_vruntime;
#ifndef CONFIG_64BIT
  u64 min_vruntime_copy;
#endif
  struct rb_root tasks_timeline; //指向的就是红黑树的根节点
  struct rb_node *rb_leftmost;  //rb_leftmost 指向的是最左面的节点


  struct sched_entity *curr, *next, *last, *skip;
......
};

在这里插入图片描述

sched_class 定义的与调度有关的函数

enqueue_task 向就绪队列中添加一个进程,当某个进程进入可运行状态时,调用这个函数;
dequeue_task 将一个进程从就绪队列中删除;
pick_next_task 选择接下来要运行的进程;
put_prev_task 用另一个进程代替当前运行的进程;
set_curr_task 用于修改调度策略;
task_tick 每次周期性时钟到的时候,这个函数被调用,可能触发调度

在这里插入图片描述

如果优先队列一直有任务,普通队列的task一直得不到处理,操作系统会怎么做呢?


为了防止这种现象的发生,操作系统在一定的时间周期会重置所有task的优先级,这样就保证了低优先级的task得以执行,而不被饿死。但是这个时间设置为多少合适?设置的短了会导致系统的频繁重置。设置的长了,又会使普通优先级的task切换太慢。这个时间一般是系统研究人员研究得到的,我觉得可能可以通过一些统计学上的方式来做。
为了解决task响应时间和完成时间的平衡,现代操作系统如Windows和Linux都依赖于Multi-Level Feedback Queue, 和文章讲的正好对应起来了。首先面对的情况是:

  1. 操作系统无法知道每个task何时到来 ?
  2. 操作系统无法知道每个task运行完成实际需要多少时间 ?

那么FIFO ShortJobFirst或者Short Time Completed First 算法,面对这两种场景将无从下手。
面对这样的问题,为了使交互性的TASK能够得到快速的响应,提升用户的的体验,同时缩短task 的完成时间。计算机科学家提出了Multi-Level Feedback Queue的解决方案。基本思想是通过优先级保证交互性的task,能够快速响应,同时通过统计task 对CPU的使用时间以期对TASK判断,有点类似于机器学习。
如果某个task 在其时间片里用完前释放CPU, 可以认为这是种交互式的task, 优先级保留。反之认为某个task是需要运行时间长的。同时基于对task 对cpu 时间使用的统计作为判断依据。这样经过一段时间运行后,长时间运行的队列会被逐渐降低优先级
而快速响应的task 能够优先使用CPU。但是这里面还有两个问题: 首先,如果优先级低的一直得不到cpu, 可能会出现饿死。其次,有人可能会利用这个漏洞编程的方式在使用完CPU时间片后释放CPU,从而控制CPU。 基于此,Multi-feedback-queue有以下5条规则:
4. 如果A的优先级大于B, 则A先运行。
5. 如果A的优先级等于B, 则以RR算法交互运行。
6. 新来的 Task 会被置于最高的优先级。
7. 如果一个task 在其当前优先级运行完被分配的时间片后,会降低其优先级,重置其放弃使用CPU的次数。(这条规则修改过,是为了防止有人利于原有规则的漏洞控制CPU, 原来的规则是如果一个task 在其时间片用完前释放cpu, 则其优先级保持不变, 这个修正增加了对task 实际使用cpu 时间统计作为判断依据)。
8. 系统每过时钟周期的倍数,会重置所有task 的优先级。(这条规则是为了防止task被饿死的,也是我之前所疑惑的)。

进程主动调度

在这里插入图片描述运行中的进程主动调用 __schedule 让出 CPU。在 __schedule 里面会做两件事情,第一是选取下一个进程,第二是进行上下文切换。而上下文切换又分用户态进程空间的切换和内核态的切换。

调度, 切换运行进程, 有两种方式
- 进程调用 sleep 或等待 I/O, 主动让出 CPU
- 进程运行一段时间, 被动让出 CPU

抢占式调度

在这里插入图片描述 抢占式调度

假如没有系统调用等,那岂不是会死循环

简单来说就是如果发生了中断,那么当前进程肯定会陷入内核态。所以可能会有标记步骤和真正的抢占步骤。详细点来说,当一个进程正在 CPU 上运行,如果发生时钟中断,那么需要去处理这个时钟中断,也就是会调用相应的中断处理函数,而相应的中断处理函数需要在内核态下执行,所以当前进程会陷入内核态,然后保存用户态的情况,然后判断是否需要进行标记。然后中断函数处理完之后,会返回用户态,这个时候又会发生抢占。

进程的创建

在这里插入图片描述

调用 alloc_task_struct_node 分配一个 task_struct 结构;

调用 alloc_thread_stack_node 来创建内核栈,这里面调用 __vmalloc_node_range 
分配一个连续的 THREAD_SIZE 的内存空间,赋值给 task_struct 的 void *stack 成员变量;

调用 arch_dup_task_struct(struct task_struct *dst, struct task_struct *src),将 
task_struct 进行复制,其实就是调用 memcpy;

调用 setup_thread_stack 设置 thread_info。

整个 task_struct 复制了一份,而且内核栈也创建好了

调用 prepare_creds,准备一个新的 struct cred *new。如何准备呢?
其实还是从内存中分配一个新的 struct cred 结构,然后调用 memcpy 复制一份父进程的 cred;
接着 p->cred = p->real_cred = get_cred(new),将新进程的“我能操作谁”和“谁能操作我”两个权限都指向新的 cred。
sched_fork 主要做了下面几件事情:
fork 的第二件大事:唤醒新进程

void wake_up_new_task(struct task_struct *p)
{
  struct rq_flags rf;
  struct rq *rq;
......
  p->state = TASK_RUNNING;
......
  activate_task(rq, p, ENQUEUE_NOCLOCK);
  p->on_rq = TASK_ON_RQ_QUEUED;
  trace_sched_wakeup_new(p);
  check_preempt_curr(rq, p, WF_FORK);
......
}

将进程的状态设置为 TASK_RUNNING。activate_task 函数中会调用 enqueue_task。


static inline void enqueue_task(struct rq *rq, struct task_struct *p, int flags)
{
.....
  p->sched_class->enqueue_task(rq, p, flags);
}

如果是 CFS 的调度类,则执行相应的 enqueue_task_fair。


static void
enqueue_task_fair(struct rq *rq, struct task_struct *p, int flags)
{
  struct cfs_rq *cfs_rq;
  struct sched_entity *se = &p->se;
......
  cfs_rq = cfs_rq_of(se);
  enqueue_entity(cfs_rq, se, flags);
......
  cfs_rq->h_nr_running++;
......
}

在这里插入图片描述在这里插入图片描述- fork -> sys_call_table 转换为 sys_fork()->_do_fork

创建线程

创建进程的话,调用的系统调用是 fork,在 copy_process 函数里面,会将五大结构 files_struct、fs_struct、sighand_struct、signal_struct、mm_struct 都复制一遍,从此父进程和子进程各用各的数据结构。而创建线程的话,调用的是系统调用 clone,在 copy_process 函数里面, 五大结构仅仅是引用计数加一,也即线程共享进程的数据结构。
在这里插入图片描述- 线程的创建

  1. 设置线程属性参数, 如线程栈大小
  2. 创建用户态维护线程的结构, pthread
  3. 创建线程栈 allocate_stack
    • 取栈的大小, 在栈末尾加 guardsize
    • 在进程堆中创建线程栈(先尝试调用 get_cached_stack 从缓存回收的线程栈中取用)
    • 若无缓存线程栈, 调用 __mmap 创建
    • 将 pthread 指向栈空间中
    • 计算 guard 内存位置, 并设置保护
    • 填充 pthread 内容, 其中 specific 存放属于线程的全局变量
    • 线程栈放入 stack_used 链表中(另外 stack_cache 链表记录回收缓存的线程栈)
  4. 设置运行函数, 参数到 pthread 中
  5. 调用 create_thread 创建线程
    • 设置 clone_flags 标志位, 调用 __clone
    • clone 系统调用返回时, 应该要返回到新线程上下文中, 因此 __clone 将参数和指令位置压入栈中, 返回时从该函数开始执行
  6. 内核调用 __do_fork
    • 在 copy_process 复制 task_struct 过程中, 五大数据结构不复制, 直接引用进程的
    • 亲缘关系设置: group_leader 和 tgid 是当前进程; real_parent 与当前进程一样
    • 信号处理: 数据结构共享, 处理一样
  7. 返回用户态, 先运行 start_thread 同样函数
    • 在 start_thread 中调用用户的函数, 运行完释放相关数据
    • 如果是最后一个线程直接退出
    • 或调用 __free_tcb 释放 pthread 以及线程栈, 从 stack_used 移到 stack_cache 中

进程和线程的异同点:

  1. 进程有独立的内存空间,比如代码段,数据段。线程则是共享进程的内存空间。
  2. 在创建新进程的时候,会将父进程的所有五大数据结构复制新的,形成自己新的内存空间数据,而在创建新线程的时候,则是引用进程的五大数据结构数据,但是线程会有自己的私有(局部)数据,执行栈空间。
  3. 进程和线程其实在cpu看来都是task_struct结构的一个封装,执行不同task即可,而且在cpu看来就是在执行这些task时候遵循对应的调度策略以及上下文资源切换定义,包括寄存器地址切换,内核栈切换,指令指针寄存器的地址切换。所以对于cpu而言,进程和线程是没有区别的。
  4. 进程创建的时候直接使用系统调用fork,进行系统调用的链路走,从而进入到_do_fork去创建task,而线程创建在调用_do_fork之前,还需要维护pthread这个数据结构的信息,初始化用户态栈信息。

linux 线程有自己独立的内核栈(内核栈中存储着pcb)吗?
疑问:首先,我们知道所有线程共享主线程的虚拟地址空间(current->mm指向同一个地址),且都有自己的用户态堆栈(共享父进程的地址空间,再在里面分配自己的独立栈,默认2M)。这是毫无疑问的,但还有一点我没搞明白,内核栈是共享还是独立的?
回答:独立的。理由:要不然内核栈对应的thread_info中的tast_struct(pcb进程控制块)没有办法与每个线程对应起来,因为现在已经有多个task_struct了,但保存内核栈的thread_info(其实是thread_union联合体)中只能保存一个task_struct。所以理论上分析,虽然可以共享地址空间,但每个线程还是需要一个单独的内核栈的。

  1. 读Linux内核以及相关的资料的时候,时刻要清醒地认识到它说的是内核态还是用户态的东西。
  2. 一个用户态进程/线程在内核中都是用一个task_struct的实例描述的,这个有点类似设计模式里面的桥接模式(handle-body), 用户态看到的进程PID,线程TID都是handle, task_struct是body。
  3. C语言书里面讲的堆、栈大部分都是用户态的概念,用户态的堆、栈对应用户进程虚拟地址空间里的一个区域,栈向下增长,堆用malloc分配,向上增长。
  4. 用户空间的堆栈,在task_struct->mm->vm_area里面描述,都是属于进程虚拟地址空间的一个区域。
    5.而内核态的栈在tsak_struct->stack里面描述,其底部是thread_info对象,thread_info可以用来快速获取task_struct对象。整个stack区域一般只有一个内存页(可配置),32位机器也就是4KB。
  5. 所以说,一个进程的内核栈,也是进程私有的,只是在task_struct->stack里面获取。7. 内核态没有进程堆的概念,用kmalloc()分配内存,实际上是Linux内核统一管理的,一般用slab分配器,也就是一个内存缓存池,管理所有可以kmalloc()分配的内存。所以从原理上看,在Linux内核态,kmalloc分配的所有的内存,都是可以被所有运行在Linux内核态的task访问到的。

标签:task,struct,--,调用,趣谈,内核,linux,进程,rq
来源: https://blog.csdn.net/qq_29066533/article/details/115006250