一、创建
所有的进程都是PID为1的init进程的后代。
内核在系统启动的最后阶段启动init进程。该进程读取系统的初始化脚本(initscript)并执行其他的相关程序,最终完成系统启动的整个过程。
通过 fork() 调用是通过拷贝当前进程来创建子进程。此时父子进程的区别在于PID(本进程号),ppid(父进程号)和一些资源和统计量。
通过 exec() 函数用于加载可执行文件开始运行。(当我们在 Linux 下的 bash 下输入一个命令执行可执行程序时,bash 进程会调用 fork() 创建一个新的进程,然后新的进程调用 execve() 系统调用来执行指定的可执行程序。)
当用户调用 fork()时,会进入系统调用 sys_fork()
int sys_fork(struct pt_regs *regs)return do_fork(SIGCHLD, regs->sp, regs, 0, NULL, NULL);
从 do_fork 的第一个参数 clone_flags 可知,do_fork 只传递了一个当子进程去世时向父进程发送的信号 SIGCHLD。
- 调用 copy_process 为子进程复制一份描述符信息。
- 将子进程加入运行队列并将其唤醒运行
- 若是调用 vfork() 则父进程等待子进程执行完成。
dup_task_struct 为新进程创建一个内核栈、thread_info 和 task_struct 结构,这些结构中的信息完全复制了父进程信息,同时完成了 thread_info 和 task_struct 之间的关系。
初始化时每个task基本都一样,但寄存器不一样,寄存器需要装载不通的指令地址和数据地址。
在 copy_process 中通过 dup_task_struct 为子进程分配了描述结构并初始化,完成内核栈的低端数据的初始化,而用作内核堆栈的高端复制初始化由 copy_thread 来完成。
通过 copy_thread 初始化子进程内核栈的高端地址,修改其中的寄存器,保证了子进程被调度运行返回时能够和父进程进行了区分。
我们知道应用程调用 fork() 会返回2次,父进程返回的是子进程的 id, 子进程返回0,那子进程是怎么返回的呢?
在 copy_thread 函数将子进程的 eip 寄存器值设置为 ret_from_fork 的地址,同时将 eax 寄存器中的值赋值为0(eax 记录的就是函数返回时的值)。
当子进程被调度运行时,子进程进入 ret_from_fork,在调用完 schedule_tail 后调到 syscall_exit 结束系统调用返回到用户空间,用户空间从 eax 寄存器中获取返回值0,也即是调用 fork 的返回值。
写时拷贝
通过 dup_task_struct 复制进程时,会把父进程的内存也也复制给了子进程。当父进程在复制的时候把内存设置为只读然后再复制,后面如果子进程不写就算了,一旦有写入操作就会引起缺页异常的中断,然后系统重新给分配内存页同时修改页表,这样子进程父进程的内存就分开了。
在linux中还有一种创建进程的方式,那就是vfork
通过 copy_process ,子进程完全复制复制了父进程的一些资源信息。
vfork 在调用 copy_process 时,由于存在 CLONE_VM 标志,所以在 拷贝 copy_mm 时子进程并不对父进程 mm_struct 结构进行复制,而是子进程指向父进程的 mm_struct结构进行共享。
由于子进程指向父进程的mm_struct结构,所以当子进程修改数据的时候父进程能够感知到。
线程
Linux 中实现线程的机制很特别。从内核的角度来看,并没有线程的概念。Linux 把所有的线程当做进程来实现。线程仅仅被视为一个与其他进程共享某些资源的进程。每个线程都拥有唯一隶属于自己的 task_struct。所以在内核中,它看起来像是一个普通的进程(只是线程和其他一些进程共享某些资源,比如地址空间等)。
在用户态中我们常用 pthread_create 来创建线程,而 pthread_create 在libc 库中调用 create_thread(), 最终调用 clone()。
__pthread_create_2_1 ()-->ALLOCATE_STACK () 分配线程栈空间--> create_thread ()--> __clone2 (),通过 libc 库创建了线程的栈,所以每个线程都有自己的私有栈。
在内核实现中,最终还是调用 do_fork。
线程的创建和普通进程的创建类似,只不过在调用 clone() 的时候需要传递一些参数标志来指明需要共享的资源。
线程共享了父进程的地址空间、打开的文件、文件系统信息、信号处理函数及被阻断的信号等信息。const int clone_flags = (CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SYSVSEM| CLONE_SIGHAND | CLONE_THREAD| CLONE_SETTLS | CLONE_PARENT_SETTID| CLONE_CHILD_CLEARTID。
线程和进程都是一个task_struct,但线程的 task_struct 不需要额外再分配资源,完全共享进程的,所以线程的资源暂用比进程小。进程的运行载体是线程,每个线程的调度都是以 task_struct 为单位的。
二、任务队列(操作系统任务调度队列)
首先,在linux里面,没有所谓的线程和进程。无论是进程还是线程,到了内核里面统一叫任务(task),有一个统一的结构task_struct进行管理。
无全局变量,一切靠统一调度,所以不需要用到锁。
内核把进程的列表存放在叫做任务队列(task list) 的双向循环链表中。链表中的每一 项都是类型为task_struct
进程描述符
struct task_struct,定义在<linux/sched.h>文件中。包含打开的文件,进程的地址空间,挂起的信号,进程的状态等。
通过slab分配器分配,通过预先分配和重复使用task_sturct,避免动态分配和释放所带来的资源消耗(对象复用和缓存着色)。
进程队列的全局变量 current
当前正在运行的进程的指针,硬件体系结构不同,该宏的实现也不同,它必须针对专门的硬件体系结构做处理。
有的硬件体系结构可以拿出一个专门寄存器来存放指向当进程task_struct的指针,用于加快访问速度,如多数64位Linux系统。
而有些像x86这样的体系结 (其寄存器并不富余),就只能在内栈的尾端创建thread_info结构,通过计算偏移间接地查找task_struct结构。
进程调度
进程状态
TASK_RUNNING(运行)——进程是可执行的。它或者正在执行,或者在运行队列中等待执行,这是进程在用户空间中执行的唯一可能的状态, 这种状态也可以应用到内核空间中正在执行的进程;
TASK_INTERRUPTIBLE(可中断)——进程正在睡眠(也就是说它被阻塞),等待某些条件的达成。一旦这些条件达成,内核就会把进程状态设置为运行。处于此状态的进程也会因为接收到信号而提前被唤醒并随时准备投入运行;
TASK_UNINTERRUPTIBLE (不可中断)——除了就算是接收到信号也不会被唤醒或准备投入运行外,这个状态与可打断状态相同。这个状态通常在进程必须在等待时不受干扰或等待事件很快就会发生时出现。由于处于此状态的任务对信号不做响应,所以较之可中断状态,使用得较少,
这就是你在执ps命令时,看到那些被标为D状态而又不能被杀死的进程的原因。由于任务将不响应信号,因此,你不可能给它发送SIGKILL信号。退一步说,即使有办法,终结这样一个任务也不是明智的选择 ,因为该任务有可能正在执行重要的操作,甚至还可能持有一个佶号置;
__TASK_TRACED——被其他进程跟踪的进程,例如通过ptrace对调试程序进行跟踪;
__TASK_TSTOPPED(停止)——进程停止执行。进程没有投入运行也不能投入运行,通常这种状态发生在接收到SIGSTOP、SIGTSTP、SIGTTIN、SIGTTO U等信号的时候。此外,调试期间接收到任何信号,都会使进程进入这种状态。
三、栈
在用户态,应用程序进行了至少一次函数调用。32位和64的传递参数的方式稍有不同,32位的就是用函数栈,64位的前6个参数用寄存器,其他的用函数栈。
在内核态,32位和64位都使用内核栈,格式也稍有不同,主要集中在pt_regs结构上。
在内核态,32位和64位的内核栈和task_struct的关联关系不同。32位主要靠thread_info, 64位主要靠Per-CPU变量。
ESP(Extended Stack Pointer):栈顶指针寄存器,CPU持有。
EBP(Extended Base Pointer):栈基地址指针寄存器,指向当前栈帧的最底部。
32位用户态:
如上图,得到stack,thread_info或task_struct任意一个数据结构的地址,就可以很快得到另外两个数据的地址。
thread_info则是一个与进程描述符相关的小数据结构,它同进程的内核态栈stack存放在一个单独为进程分配的内存区域。由于这个内存区域同时保存了thread_info和stack,所以使用了联合体来定义。
union thread_union { struct thread_info thread_info; unsigned long stack[THREAD_SIZE/sizeof(long)]; };
thread_info的位置是内核栈的最高位置减去THREAD_SIZE,就到了thread_info的起始地址。拿到thread_info的起始地址之后通过task可以拿到task_sturct。
根据内核的配置,THREAD_SIZE既可以是4K字节(1个页面)也可以是8K字节(2个页面)。thread_info是52个字节长。
64位用户态:
64位操作系统的寄存器数目比较多。
寄存器rax用于保存函数调用的返回结果,栈顶指针寄存器变成了rsp,堆栈的Pop和Push操作会自动调整rsp,栈基指针寄存器变成了rbp。
64位系统,每个CPU运行的task_struct不通过thread_info获取了,而是直接放在Per CPU 变量里面了。
多核情况下,CPU是同时运行的,但是它们共同使用其他的硬件资源的时候,我们需要解决多个 CPU之间的同步问题。
Per CPU变量是内核中一种重要的同步机制。顾名思义,Per CPU变量就是为每个CPU构造一个 变量的副本,这样多个CPU各自操作自己的副本,互不干涉。比如,当前进程的变量 current_task就被声明为Per CPU变量。
DEFINE_PER_CPU(struct task_struct *, current_task) = &init_task;
系统刚刚初始化的时候,current_task都指向init_task,当某个CPU上的进程进行切换的时候,current_task被修改为将要切换到的目标进程,当要获取当前的运行中的task_struct的时候,就需要调用this_cpu_read_stable进行读取,这样如果你是一个进程,正在某个CPU上运行,就能够轻松得到task_struct。
内核态:
内核中也有各种各样的函数调用来调用去的,也需要一个栈机制。
在内核栈的最高地址端,存放的是另一个结构pt_regs,32位和64位的定义不一样:
#ifdef __i386__
struct pt_regs {unsigned long bx; unsigned long cx; ......,unsigned long sp;unsigned long ss;};
#else struct pt_regs {unsigned long r15; unsigned long r14;......,unsigned long sp;unsigned long ss;/* top of stack page */};
#endif
当系统调用从用户态到内核态的时候,首先要做的第一件事情,就是将用户态运行过程中的CPU上下文保存起来,其实主要就是保存在这个结构的寄存器变量里。这样当从内核系统调用返回的时候,才能让进程在刚才的地方接着运行下去。
四、调度
在Linux中,进程(Linux中用轻量级的进程来模拟线程)使用的核心数据结构。一个进程在核心中使用一个task_struct结构来表示,包含了大量描述该进程的信息,其中与调度器相关的信息主要包括以下几个:
state
volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */
Linux的进程状态主要分为三类:可运行的(TASK_RUNNING,相当于运行态和就绪态);被挂起的(TASK_INTERRUPTIBLE、TASK_UNINTERRUPTIBLE和TASK_STOPPED);不可运行的(TASK_ZOMBIE),调度器主要处理的是可运行和被挂起两种状态下的进程,其中TASK_STOPPED又专门用于SIGSTP等IPC信号的响应,而TASK_ZOMBIE指的是已退出而暂时没有被父进程收回资源的"僵死"进程。
counter
long counter;
该属性记录的是当前时间片内该进程还允许运行的时间。
就绪进程选择算法(即进程调度算法,文件:/kernel/sched.c)
上下文切换
从一个进程的上下文切换到另一个进程的上下文,因为其发生频率很高,所以通常都是调度器效率高低的关键。schedule()函数中调用了switch_to宏,这个宏实现了进程之间的真正切换,其代码存放于include/i386/system.h。switch_to宏是用嵌入式汇编写成的,较难理解。
由switch_to()实现,而它的代码段在schedule()过程中调用,以一个宏实现。
switch_to()函数正常返回,栈上的返回地址是新进程的task_struct::thread::eip,即新进程上一次被挂起时设置的继续运行的位置(上一次执行switch_to()时的标号"1:"位置)。至此转入新进程的上下文中运行。
这其中涉及到wakeup,sleepon等函数来对进程进行睡眠与唤醒操作。
选择算法
Linux schedule()函数将遍历就绪队列中的所有进程,调用goodness()函数计算每一个进程的权值weight,从中选择权值最大的进程投入运行。
Linux的调度器主要实现在schedule()函数中。
a. 时间片轮转调度算法: 时间片(time slice)就是分配给进程运行的一段时间。
b. 优先权调度算法(非抢占式优先权算法、抢占式优先调度算法)。
c. 多级反馈队列调度: 这种调度算法本质:综合时间片轮转调度和抢占式优先权调度的优点,优先权高的进程先运行给定的时间片,相同优先权的里程轮流进行给定时间片。
d. 实时调度(实时系统,就是系统对外部事件有求必应、尽快响应)。
调度类型
Linux 调度器将进程分为三类:
1. 交互式进程
此类进程有大量的人机交互,因此进程不断地处于睡眠状态,等待用户输入。典型的应用比如编辑器 vi。此类进程对系统响应时间要求比较高,否则用户会感觉系统反应迟缓。
2. 批处理进程
此类进程不需要人机交互,在后台运行,需要占用大量的系统资源。但是能够忍受响应延迟。比如编译器。
3. 实时进程
实时对调度延迟的要求最高,这些进程往往执行非常重要的操作,要求立即响应并执行。比如视频播放软件或飞机飞行控制系统
根据进程的不同分类 Linux 采用不同的调度策略。对于实时进程,采用 FIFO 或者 Round Robin 的调度策略。对于普通进程,则需要区分交互式和批处理式的不同。传统 Linux 调度器提高交互式应用的优先级,使得它们能更快地被调度。而 CFS 和 RSDL 等新的调度器的核心思想是“完全公平”。这个设计理念不仅大大简化了调度器的代码复杂度,还对各种调度需求的提供了更完美的支持。
调度步骤:
Schedule函数工作流程如下:
(1)清理当前运行中的进程
(2)选择下一个要运行的进程(pick_next_task)
(3)设置新进程的运行环境
(4) 进程上下文切换
内核调度和内核的理解
1. 内核调度也算是一个任务吗??
答:不,内核调度只能说是一种任务调度的算法,它不一直在运行,只是在任务结束/时间片结束的时候才执行,选择下一个要运行的任务。
2. 任务和内核的关系?
答:任务是运行在内核的管理之下的,也可以说任务是运行在内核的这个环境里的。
内核调度只是内核功能的一部份。内核本身不存在调度,它可以说一直在运行,主要是运行在任务之内和之间,它负责任务所需的资源处理。
3. 它和正在运行的那个最高优先级的任务是一种什么样的关联呢??
答:不管优先级多高,它都是运行在内核环境下的,内核是一直在运行的,只不过它是把CPU和其它资源分配给任务,让它运行而已。
4. 什么是内核?
答:其实内核不是一个进程,也不是一个现程。
内核通过他提供的api,融合进了应用程序。也就是说内核只是一种抽象的说法,他本身并不存在,而是在一些特定的时间和特定的条件才运行,才给我们的应用程序提供各种服务。
进程,线程的挂起后会调这个调度函数。
static void __sched notrace __schedule(bool preempt)
{
struct task_struct *prev, *next;
unsigned long *switch_count;
struct pin_cookie cookie;
struct rq *rq;
int cpu;
cpu = smp_processor_id();
rq = cpu_rq(cpu);
prev = rq->curr;
if (!preempt && prev->state) {
if (unlikely(signal_pending_state(prev->state, prev))) {
prev->state = TASK_RUNNING;
} else {
deactivate_task(rq, prev, DEQUEUE_SLEEP);
prev->on_rq = 0;
}
}
next = pick_next_task(rq, prev, cookie);
clear_tsk_need_resched(prev);
clear_preempt_need_resched();
if (likely(prev != next)) {
rq->nr_switches++;
rq->curr = next;
++*switch_count;
rq = context_switch(rq, prev, next, cookie); /* unlocks the rq */
} else {
lockdep_unpin_lock(&rq->lock, cookie);
raw_spin_unlock_irq(&rq->lock);
}
}