zoukankan      html  css  js  c++  java
  • 操作系统(四)—— 进程和线程基础

    概述

      在前几篇讲内存管理的时候,提到地址空间是内存的抽象。那进程就是CPU的抽象,一个程序运行起来以后就是一个进程,线程是进程创建出来的,其本身并不能独立运行,一个进程可以创建出来多个线程,他们之间会共享进程的堆空间,公共变量等。本文就详细介绍一个进程和线程基础。

    进程

    进程定义

    一个具有一定独立功能的程序在一个数据集合上的一次执行过程。这是清华大学操作系统公开课上给的定义。觉得不用做过多解释,相信大家应该都明白。

    进程的组成

    • 程序计数器
    • 打开的文件
    • 独立的虚拟地址空间

    进程控制块(PCB)

    操作系统用于管理进程所用的信息集合。linux中 task_struct描述符结构体表示一个进程的控制块,这个 task_struct结构记录了这个进程所有的context (进程上下文信息)

    struct task_struct{
        //列出部分字段
        volatitle long state;//表示进程当前的状态 ,-1表示不可运行,0表示可运行,>0表示停止
        void                *stack; //进程内核栈
        unsigned int            ptrace;
    
        pid_t               pid;//进程号
        pid_t               tgid;//进程组号
        struct              mm_struct *mm,*active_mm //用户空间 内核空间
    
        struct              list_head thread_group;//该进程的所有线程链表
        struct              list_head thread_node;
        int                 leader;//表示进程是否为会话主管
        struct              thread_struct thread;//该进程在特定CPU下的状态
    
        //等等字段:包括一些 表示 使用的文件描述符、该进程被CPU调度的次数、时间、父子、兄弟进程等信息
    }

    在linux中,进程的信息在/proc文件夹下保存着。如果想看某个pid的信息,可以使用ps或者top等命令获取。进程控制块在内存的组织形式有两种方式,一种是链表方式,每个PCB以链表的方式连接在一起,另一种是索引的方式,索引的指针指向进程控制块。系统中的所有进程的信息都会保存到进程控制块的链表中。

    进程的状态

    由于现代操作系统,大多是采用时间片的方式来执行进程,就是每个进程执行一段时间,相互交替执行,所以进程就有了很多的中间状态,下面就介绍一下这些状态。

             

                       图片来源:现代操作系统

    • 运行状态:程序正常运行,占用CPU时间片
    • 阻塞状态:如果程序执行一个I/O操作,CPU往往会进行上下文切换,执行别的进程,那当前进程就变成了阻塞状态
    • 就绪状态:程序已经准备好,可以执行,但是CPU时间片还没有分配给当前进程

    这里说明一下什么是时间片,举个例子,CPU给每个进程的执行时间是20ns,那如果某个进程20ns没有执行完,会切换到别的进程执行,那20ns就是时间片的大小。

    在解释一下上面状态之间的切换。

    • 1,运行状态到阻塞状态,进程执行某个操作必须等待,比如程序执行一个I/O操作,CPU会把当前进程进入阻塞状态执行别的进程
    • 2,运行状态到就绪状态,分配给当前进程的时间片用完
    • 3,就绪状态到运行状态,被进程调度程序选中,开始执行
    • 4,阻塞状态到就绪状态,程序等待某个事件的到来

     上面只介绍了三种状态,其实还有一种状态,僵尸状态,下面就简略介绍一下

    父进程可以通过fork来创建子进程,当子进程结束的时候,可以直接调用exit退出,这个时候进程的资源就会全部被回收,但是进程控制块是操作系统管理的,这个还没有被回收,由于进程用户态资源已经全部释放了,无法再回到用户态发出系统调用,回收进程控制块(PCB),只能交给他的父进程进行回收,在程序执行了exit,而父进程还没有回收进程控制块这段时间进程既不是就绪状态,也不是等待状态,而是处于一种僵尸状态(就是半死不死的状态)。上面的解释是清华大学操作系统公开课老师给的一种解释,不太明白为什么在进程资源被回收完之后,不把进程的唯一标示PCB也给回收掉,而要交给父进程进行回收,有明白的胖友,欢迎指教。

    线程

    线程其实是一种轻量级进程,通常一个进程由多个线程组成,各个线程共享进程的内存空间(包括数据,代码,堆,打开的文件,信号等),一个典型的线程和进程的关系图如下

        

              图片来源:程序员的自我修养

    线程的访问权限

    线程之间虽然可以共享同一个进程的很多资源,但是线程仍然有私有存储空间,如下

    • 栈(并非完全无法被其他线程访问,但是一般情况下仍然认为是线程私有,以上这段话来自程序员的自我修养,不太懂为什么线程的栈,别的线程也可以访问
    • 线程局部存储(Thread Local Storage 简称TLS),是操作系统提供的一个很有限的空间,java中的ThreadLocal就是基于此设计的,一会再谈这个
    • 寄存器

    上面几个存储空间,我想介绍一个TLS,在牛客网上有这么一个题。

    链接:https://www.nowcoder.com/questionTerminal/a0c59b5a3e71436a86c3cc1f6392e55f
    来源:牛客网
    (多选题)
    对于线程局部存储TLS(thread local storage),以下表述正确的是
    A. 解决多线程中的对同一变量的访问冲突的一种技术
    B. TLS会为每一个线程维护一个和该线程绑定的变量的副本
    C. 每一个线程都拥有自己的变量副本,从而也就没有必要对该变量进行同步了
    D. Java平台的java.lang.ThreadLocal是TLS技术的一种实现

    牛客上给的正确答案是ABD,C之所以错误是如果TLS中保存的是变量的引用,多个线程并发修改时,还是有同步的问题,所以C错误,具体解释看这篇文章,而B说每个线程维护一个变量副本,意思是说每个线程都会获取到相同的具有初始化值的变量副本,至于之后每个线程怎么改,是相互独立的。

    这篇文章更详细的介绍了TLS,有兴趣的可以看看:有必要澄清一下究竟TLS(or java's thread local)的作用是什么

    从数据的角度来看,是否私有如下表

      

    多线程和多进程的区别 

    • 多线程之间堆内存共享,而进程相互独立,线程间通信可以直接基于共享内存来实现,比进程的常用的那些多进程通信方式更轻量(这个牵涉到线程间和进程间通信,本文没有讲)
    • 在上下文切换来说,不管是多线程还是都进程都涉及到寄存器、栈的保存,但是线程不需要切换 页面映射(虚拟内存空间)、文件描述符等,所以线程的上下文切换也比多进程轻量
    • 多进程比多线程更安全,一个进程基本上不会影响另外一个进程 
      在实际的开发中,一般不同任务间(可以把一个线程、进程叫做一个任务)需要通信,使用多线程的场景比多进程多。但是多进程有更高的容错性,一个进程的crash不会导致整个系统的崩溃,在任务安全性较高的情况下,采用多进程。

     上下文切换

      由于进程调度还没有写,不过大家应该都听说过操作系统分时调度,那既然要切换不同的进程,就要把之前运行的进程一些信息给保存起来,什么信息呢,比如寄存器中的临时变量,程序计数器等,然后加载另外一个进程的临时变量,程序计数器,并跳转到指定的地方运行,这个过程就叫做上下文切换。

    上下文切换的类型

    • 进程上下文切换
    • 线程上下文切换
    • 中断上下文切换

    下面就详细介绍一下这三种上下文切换。

    进程上下文切换

    进程上下文分为进程内上下文切换和进程间上下文切换,先介绍进程内上下文切换。

    进程内上下文切换--系统调用

    在现代操作系统中,有两种运行状态,用户态和内核态,用户态运行着特权级别比较低的程序,只能访问部分资源,内核态运行着特权级别比较高的程序,可以访问所有的硬件和功能,比如操作系统,而处于用户态的程序如果想要执行某个特权级别比较高的操作,就需要调用操作系统暴露的接口,这个过程叫做系统调用,而系统调用需要程序从用户态切换到内核态运行,这个过程会发生上下文切换。

    举个例子,printf("hello world"),这个操作就需要系统调用,上下文切换的过程如下

    1. 保存 CPU 寄存器里原来用户态的指令位
    2. 为了执行内核态代码,CPU 寄存器需要更新为内核态指令的新位置。 
    3. 跳转到内核态运行内核任务。 
    4. 当系统调用结束后,CPU 寄存器需要恢复原来保存的用户态,然后再切换到用户空间,继续运行进程。

    所以一次系统调用发生了两次上下文切换(用户态 -> 内核态 -> 用户态)

    系统调用上下文切换,切换到内核态之后,并不需要虚拟内存等用户空间的资源,而且也不会切换进程,只需要加载内核态的程序计数器和寄存器、堆、栈等资源。

    进程间上下文切换

    进程上下文切换的场景如下

    • 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行。
    • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行。
    • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度。
    • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行
    • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序。

    由于进程是由内核管理的,因此进程的切换只能发生在内核态,所以进程间上下文切换不光需要把用户态的寄存器、程序计数器、堆栈,虚拟地址空间等信息保存起来,还需要把内核中的进程的状态,堆栈信息保存起来。

    系统调用和进程间上线文切换的区别

    进程间上下文切换就比系统调用多了一步,切换到另一个进程的时候需要加载进程用户态的资源,而系统调用,进入内核态的时候是不牵涉到用户态的资源的,所以就无须加载用户态的资源。

    小结

    操作系统为了安全,限制用户程序的特权级别,就牺牲了效率。同样,操作系统为了公平,让每个程序都有执行的机会,搞了个分时调度,同样牺牲了效率(不包括程序执行I/O等操作的情况,这种情况切换别的进程执行是提高效率的◠◡◠),上下文切换时间不只是浪费在需要保存寄存器,堆栈等信息和重新加载另一个进程的寄存器,堆栈信息。还有之前介绍虚拟地址空间的时候,介绍过,为了加快虚拟地址和物理地址的映射,在CPU中有一个MMU,MMU中有一个TLB硬件,用来缓存最近经常使用的映射关系,那如果发生上下文切换就需要从内存中的页表中读取映射关系,再缓存到TLB中,这个过程也会浪费时间。

    线程上下文切换

    上面已经介绍过线程,同一个进程的线程会共享很多的资源,这些在线程上线文切换的时候是不需要保存的,需要保存的是线程自己私有的数据,就是上面介绍过的寄存器,栈,TLB等。

    中断上下文切换

    中断,在第一篇文章中已经介绍过,这里就不介绍了,中断上下文切换有点像系统调用,因为系统调用和中断都是操作系统执行的,所以都发生在内核态,也就是说在处理中断的时候不涉及到用户态的虚拟地址空间、堆、栈等信息。

    总结

    本文介绍了进程、线程、上下文切换 。进程介绍了进程的组成和进程的状态,线程介绍了线程的组成,上下文切换分别介绍了进程上下文切换、线程上下文切换、中断上线文切换,总的来说把基础的知识给概括的介绍了一下,之后会介绍进程调度和线程安全相关的内容。

                    

     参考:

    《现代操作系统》

    《程序员的自我修养》

    清华大学操作系统公开课

    一文让你明白CPU上下文切换

    浅析操作系统的进程、线程区别

  • 相关阅读:
    pipelinewise 学习二 创建一个简单的pipeline
    pipelinewise 学习一 docker方式安装
    Supercharging your ETL with Airflow and Singer
    ubuntu中使用 alien安装rpm包
    PipelineWise illustrates the power of Singer
    pipelinewise 基于singer 指南的的数据pipeline 工具
    关于singer elt 的几篇很不错的文章
    npkill 一个方便的npm 包清理工具
    kuma docker-compose 环境试用
    kuma 学习四 策略
  • 原文地址:https://www.cnblogs.com/gunduzi/p/13504298.html
Copyright © 2011-2022 走看看