文章目录
前言
书本上的定义: 线程是调度的基本单位, 进程是资源分配的基本单位
应该大家都知道这个概念, 但是进程和线程之间的异同绝不仅仅是这一句话这么简单
进程概念
进程是由正文段 (存放被执行的机器指令), 用户数据段 (存放进程在执行时直接进行操作的所有数据, 包括进程使用的全部变量在内), 系统数据段 (存放程序运行的环境)
而这个所谓的系统数据段也正是进程中最重要的一部分, 它就是进程和程序的区别所在, 进程作为一个动态的程序实例, 这一部分就存放着进程的控制信息, 每个进程都有一个 task_strust 数据结构 (也就是 PCB) 来存放这些控制信息.
操作系统也是通过这些控制信息 (task_struct) 来控制调度所有进程的
线程概念
线程由线程ID, 当前指令指针, 寄存器集合和堆栈组成
线程是进程的一个实体, 线程不拥有系统资源, 只拥有一点运行中必不可少的资源 (属于他自己的调用栈, 一组寄存器, 栈), 但是它和同属于一个进程的其他线程共享进程所拥有的全部资源
地址空间
进程间的地址空间 (通过虚拟内存技术实现) 相互独立, 线程拥有的是其所属的进程的地址空间, 而一个进程中的多个线程共享该进程的虚拟地址空间. 而线程之所以能独立运行是因为线程的寄存器是不共享的, 每个线程独立访问自己的线程栈 (取得另一个线程的栈区的指针, 就能读写另一个线程的栈)
操作系统按进程分配地址空间, 在其中有专属于线程的线程栈.
通信手段
- 进程间通信 (IPC), 通过:
- 管道
- 信号
- 消息队列
- 共享内存
- 信号量
- 套接字
来实现
- 而线程间的通信手段主要是用来进行线程同步, 而不是数据交换:
- 想要获得另一个线程的数据可以使用全局变量
- 同步可以使用锁 (互斥锁, 条件变量, 读写锁)
- 信号量机制
- 信号机制
- Linux2.6.22后线程间事件通知还可以使用
eventfd
调度和切换
进程何时调度
- 正在正在执行的进程正确完成/由于错误终止运行(陷阱和中断)
- 执行的进程提出I/O请求,等待其完成
- 分时系统中,进程时间片用完
- 按照优先级调度时,有更高的优先级进程变为就绪时(抢占方式)
- 发生阻塞(执行的进程执行了wait、阻塞原语和唤醒原语时)
线程切换
进程切换分两步:
- 切换页目录以使用新的地址空间
- 切换内核栈和硬件上下文
线程切换虚拟内存空间依然相同, 所以对于线程切换来说, 第一步明显是不需要的, 第二步是进程 / 线程切换都需要的, 那么明显是进程切换代价大
进程的创建过程
fork 函数
在 Linux 下我们一般使用fork
创建进程 :
#include <unistd.h>
pid_t fork(void);
//成功创建一个子进程, 父进程中返回子进程ID
//子进程中返回 0
//失败返回 -1
所以不是说fork
这个函数会返回两个值, 而是在不同的进程中返回不同的值
Linux 下还可以用
vfork
和clone
来创建进程
vfork
创建的子进程完全共享了父进程的地址空间, 而且一定是子进程先执行clone
有众多参数, 控制的更为精密, 可以用来创建一个进程, 但主要是用于线程的实现的这三者最终都是通过
do_fork
函数实现的
fork
函数会完全复制父进程的地址空间
Linux 下fork
函数是通过写时复制来实现的, 也就是说当调用fork
时内核并没有将父进程的全部资源给子进程复制一份, 而是将这些内容设置为只读状态, 父子进程指向的是同一物理内存页, 当父进程 / 子进程试图修改其中的某些值时, 内核这时会在修改前将被修改的部分进行拷贝. 所以fork
的实际开销是复制父进程的页表和给子进程创建唯一的 PCB (将父进程的内容复制到新的 PCB)
fork
函数调用了内核中的do_fork
函数, 从用户态进入内核态, 内核在内存中为新进程分配新的 PCB, 同时为新进程要使用的堆栈分配物理页, 分配新的进程标识符, 然后, 这个新的 PCB 地址会被保存在链表中 (一个双向链表), 子进程创建结束后, 从内核态返回用户态
task_struct
Linux 下 PCB 由一个c语言结构体 task_struct表示
struct task_struct {
volatile long state; //-1不能运行, 0可以运行, >0为被阻塞
unsigned long flags; //Flage 是进程号,在调用fork()时给出
int sigpending; //进程上是否有待处理的信号
mm_segment_t addr_limit; //进程地址空间,区分内核进程与普通进程在内存存放的位置不同
//0-0xBFFFFFFF for user-thead
//0-0xFFFFFFFF for kernel-thread
volatile long need_resched;
int lock_depth; //锁深度
long nice; //进程的基本时间片
//进程的调度策略,有三种,实时进程:SCHED_FIFO,SCHED_RR, 分时进程:SCHED_OTHER
unsigned long policy;
struct mm_struct *mm; //进程内存管理信息
int processor;
//若进程不在任何CPU上运行, cpus_runnable 的值是0,否则是1 这个值在运行队列被锁时更新
unsigned long cpus_runnable, cpus_allowed;
struct list_head run_list; //指向运行队列的指针
unsigned long sleep_time; //进程的睡眠时间
//用于将系统中所有的进程连成一个双向循环链表, 其根是init_task
struct task_struct *next_task, *prev_task;
struct mm_struct *active_mm;
struct list_head local_pages; //指向本地页面
unsigned int allocation_order, nr_local_pages;
struct linux_binfmt *binfmt; //进程所运行的可执行文件的格式
int exit_code, exit_signal;
int pdeath_signal; //父进程终止是向子进程发送的信号
unsigned long personality;
//Linux可以运行由其他UNIX操作系统生成的符合iBCS2标准的程序
int did_exec : 1;
pid_t pid; //进程标识符,用来代表一个进程
pid_t pgrp; //进程组标识,表示进程所属的进程组
pid_t tty_old_pgrp; //进程控制终端所在的组标识
pid_t session; //进程的会话标识
pid_t tgid;
int leader; //表示进程是否为会话主管
struct task_struct *p_opptr, *p_pptr, *p_cptr, *p_ysptr, *p_osptr;
struct list_head thread_group; //线程链表
struct task_struct *pidhash_next; //用于将进程链入HASH表
struct task_struct **pidhash_pprev;
wait_queue_head_t wait_chldexit; //供wait4()使用
struct completion *vfork_done; //供vfork() 使用
unsigned long rt_priority; //实时优先级,用它计算实时进程调度时的weight值
//it_real_value,it_real_incr用于REAL定时器,单位为jiffies, 系统根据it_real_value
//设置定时器的第一个终止时间. 在定时器到期时,向进程发送SIGALRM信号,同时根据
//it_real_incr重置终止时间,it_prof_value,it_prof_incr用于Profile定时器,单位为jiffies。
//当进程运行时,不管在何种状态下,每个tick都使it_prof_value值减一,当减到0时,向进程发送
//信号SIGPROF,并根据it_prof_incr重置时间.
//it_virt_value,it_virt_value用于Virtual定时器,单位为jiffies。当进程运行时,不管在何种
//状态下,每个tick都使it_virt_value值减一当减到0时,向进程发送信号SIGVTALRM,根据
//it_virt_incr重置初值。
unsigned long it_real_value, it_prof_value, it_virt_value;
unsigned long it_real_incr, it_prof_incr, it_virt_value;
struct timer_list real_timer; //指向实时定时器的指针
struct tms times; //记录进程消耗的时间
unsigned long start_time; //进程创建的时间
//记录进程在每个CPU上所消耗的用户态时间和核心态时间
long per_cpu_utime[NR_CPUS], per_cpu_stime[NR_CPUS];
//内存缺页和交换信息:
//min_flt, maj_flt累计进程的次缺页数(Copy on Write页和匿名页)和主缺页数(从映射文件或交换
//设备读入的页面数); nswap记录进程累计换出的页面数,即写到交换设备上的页面数。
//cmin_flt, cmaj_flt, cnswap记录本进程为祖先的所有子孙进程的累计次缺页数,主缺页数和换出页面数。
//在父进程回收终止的子进程时,父进程会将子进程的这些信息累计到自己结构的这些域中
unsigned long min_flt, maj_flt, nswap, cmin_flt, cmaj_flt, cnswap;
int swappable : 1; //表示进程的虚拟地址空间是否允许换出
//进程认证信息
//uid,gid为运行该进程的用户的用户标识符和组标识符,通常是进程创建者的uid,gid
//euid,egid为有效uid,gid
//fsuid,fsgid为文件系统uid,gid,这两个ID号通常与有效uid,gid相等,在检查对于文件
//系统的访问权限时使用他们。
//suid,sgid为备份uid,gid
uid_t uid, euid, suid, fsuid;
gid_t gid, egid, sgid, fsgid;
int ngroups; //记录进程在多少个用户组中
gid_t groups[NGROUPS]; //记录进程所在的组
//进程的权能,分别是有效位集合,继承位集合,允许位集合
kernel_cap_t cap_effective, cap_inheritable, cap_permitted;
int keep_capabilities : 1;
struct user_struct *user;
struct rlimit rlim[RLIM_NLIMITS]; //与进程相关的资源限制信息
unsigned short used_math; //是否使用FPU
char comm[16]; //进程正在运行的可执行文件名
//文件系统信息
int link_count, total_link_count;
//NULL if no tty 进程所在的控制终端,如果不需要控制终端,则该指针为空
struct tty_struct *tty;
unsigned int locks;
//进程间通信信息
struct sem_undo *semundo; //进程在信号灯上的所有undo操作
struct sem_queue *semsleeping; //当进程因为信号灯操作而挂起时,他在该队列中记录等待的操作
//进程的CPU状态,切换时,要保存到停止进程的task_struct中
struct thread_struct thread;
//文件系统信息
struct fs_struct *fs;
//打开文件信息
struct files_struct *files;
//信号处理函数
spinlock_t sigmask_lock;
struct signal_struct *sig; //信号处理函数
sigset_t blocked; //进程当前要阻塞的信号,每个信号对应一位
struct sigpending pending; //进程上是否有待处理的信号
unsigned long sas_ss_sp;
size_t sas_ss_size;
int(*notifier)(void *priv);
void *notifier_data;
sigset_t *notifier_mask;
u32 parent_exec_id;
u32 self_exec_id;
spinlock_t alloc_lock;
void *journal_info;
};
父子进程间的文件共享
执行fork
后, 子进程会获得父进程所有的文件描述符的副本
这意味着父子进程中对应的文件描述符均指向相同打开的文件句柄 (因为文件描述符只是文件描述符表的下标)
而文件句柄中包含着打开文件的信息 (偏移量, 文件状态), 所以加入子进程修改了文件偏移量, 父进程中也会受到影响
而只有父子进程都关闭该文件描述符时, 内核才会释放它
线程的实现
类似于 fork(), vfork(), Linux 特有的系统调用 clone() 也能创建一个进程, clone() 较之前两者在进程创建过程中对步骤的控制更为精确
clone() 主要用于线程库的实现
clone() 有损程序可移植性, 应避免直接在程序中使用
不同于 fork(), clone() 创建的子进程继续运行时不以调用处为起点, 转而去调用以参数 func 所指定的函数, func 又成为子函数
fork(), vfork(), clone() 最后都由同一函数实现, 即 do_fork(),
内核调度实体, (KSE), 实际上线程和进程都是 KSE, 只是与其他 KSE 之间对属性 (虚拟内存, 打开文件描述符, 对信号的处置, 进程 ID 等) 的共享程度不同,
- 线程有三种实现方式
一对一模型 (内核级线程) 1:1
每一线程映射一个单独的 KSE, 内核分别对每个线程做调度处理, 线程同步操作通过内核系统调用实现
遭阻塞的系统调用不会导致进程的所有线程被阻塞, 在多处理器硬件平台上, 内核还可以将进程中的多个线程调度到不同的 CPU 上
不过, 因为需要切换到内核模式, 所以诸如线程创建, 上下文切换, 以及同步操作就要慢一些, 另外为每一个线程分别维护一个 KSE, 也需要开销如果应用包含大量线程, 则可能对内核调度起造成严重负担, 降低系统整体性能
尽管有这些缺陷, 1 : 1 实现通常更胜于 M : 1 实现, LinuxThreads 和 NPTL 都采用 1 : 1 模型
多对一模型 (用户级线程) M:1
关乎线程创建, 调度以及同步的所有细节全部由进程内用户空间的线程库来处理, 从对于进程中存在的多个线程, 内核一无所知
最大优点为: 许多线程操作都很快 (线程的创建和终止, 线程上下文间的切换, 互斥量以及条件变量的操作), 因为无需切换到内核模式, 此外由于线程库无需内核支持, 所以在系统间的移植相对容易
严重缺陷: 当一个线程发起系统调用时, 控制由用户空间的线程库转到内核, 这就意味着, 如果该系统调用遭到阻塞, 那么所有的线程都将阻塞
内核无法调度进程中的这些线程, 因为内核并不知晓进程中这些线程的存在, 也就无法在多处理器平台上将各线程调度给不同的处理器, 另外, 也不可能将一进程中的某一线程的优先级调整为高于其他进程中的线程, 这是没有意义的, 因为对线程的调度完全在进程中处理
多对多模型 两级模型 M:N
每个进程都可拥有多个与之相关的 KSE, 并且也可以把多个线程映射到一个 KSE, 这种设计允许内核将同一应用的线程调度到不同的 CPU 上运行, 同时也解决了随线程数量而放大的性能问题
最大问题: 过于复杂