进程

进程模型

多道程序设计系统中,CPU由一个进程快速切换至另外一个进程。在一瞬间,CPU只能运行一个进程,在某一段时间,可能运行多个进程。

一个进程就是一个正在执行程序的实例,包括程序计数器,寄存器和变量的当前值。每个进程由自己的逻辑程序计数器。实际上,只有一个物理程序计数器,在每个程序执行的时候,逻辑程序计数器被装入实际的程序计数器中。程序暂停执行的时候,物理程序计数器被保存在内存中该进程的逻辑程序计数器中。

一个进程是某种类型的一个活动,它有程序,输入,输出以及状态。单个处理器可以被若干的进程共享。但是程序,通俗的讲是一些代码,所以操作系统可以使得不同的进程来共享代码,因此只有一个副本放在内存中。

创建进程

  • 系统初始化

启动操作系统的时候,会创建新进程,前台进程(和人类交互的进程)和后台进程,后台进程又叫做守护进程daemon

  • 运行的进程发出系统调用

  • 用户请求创建一个新进程

  • 批处理作业的初始化

其实本质上,新进程都是由于一个已经存在的进程执行了一个用于创建进程的系统调用,这个系统调用通知操作系统创建一个新进程,并且直接或者间接的指定在该进程中运行的程序。

Unix系统中,使用fork来创建新进程,这个系统调用会创建一个与调用进程相同的副本,调用fork之后,两个进程拥有相同的存储映像,同样的环境字符串和同样的打开文件。然后子进程接着执行execve或者一个类似的系统调用,以修改其存储映射并且运行一个新的程序。在执行execve之前,子进程还可以处理文件描述符,以完成对标准输入,标准输出和标准出错的重定向。

进程创建之后,父进程和子进程有各自不同的地址空间。子进程的初始地址空间是父进程的一个副本,但是是两个不同的地址空间,不可写的内存区是共享的,对于一个新创建的进程,可能共享创建者的其他资源,必须打开的文件。

进程终止

  • 正常退出

完成工作后会执行一个系统调用,通知操作系统它的工作已经完成,Unix中是exit

  • 出错退出,文件不存在之类

  • 进程引起的错误,程序错误,引用不存在内存之类

  • 某个进程执行一个系统调用通知操作系统杀死某个进程。Unix中是kill

进程的层次结构

进程只有一个父进程,但是可以有多个子进程。

Unix中,进程和它的所有后代组成一个进程组,用户从键盘发出信号时候,信号被发送给当前与键盘相关的进程组中的所有成员,每个进程可以分别捕获该信号,忽略信号或者采取默认的动作,即被信号杀死。

Unix启动时候会启动一个init进程,开始运行的时候,读入一个说明终端数量的文件,为每个终端创建一个新进程,这些进程等待用户登录,如果登陆成功,这个登陆进程就执行一个shell准备执行命令,所接收的命令会启动更多的进程。

进程的状态

  • 运行态
  • 就绪态
  • 阻塞态

等待输入时候,会进入阻塞态,出现有效输入会转入就绪态等待CPU。

进程的实现

操作系统维护这一张表格,进程表。每个进程占用一个进程表项,表项包含了进程状态的重要信息,包括程序计数器,堆栈指针,内存分配状况,打开文件的状态,账号和调度信息以及在由运行态转换到就绪态或者阻塞态必须保存的信息。

多道程序设计模型

这样可以更好的去满负荷使用CPU,Very good.

目前我的程序中IO时间大概占比在25.5%,那么对于8核处理器,如果是8个进程,那么同时在IO的概率为0.000017878103348,所以基本上已经达到很高的CPU占用率了。

计算过程如下,一个进程等待IO的时间和其在内存中的时间比为p,内存中有n个进程,n个进程都在等待I/O的比率是p^n,那么CPU的利用率是1-p^n

线程

每个进程有一个地址空间和一个控制线程,经常存在在同一个地址空间中准并行运行多个控制线程的情况,这些线程类似于分离的进程,但是又共享相同的地址空间。

线程的使用

  • 多线程,并行实体共享同一个地址空间和所有可用数据
  • 线程更加轻量级,容易创建,容易撤销,切换消耗小
  • 多IO情况,会使得这些活动重叠进行,加快程序速度

对于web服务器,既可以用多线程来处理IO操作,也可以使用read系统调用的非阻塞版本。对于非阻塞I/O来说,磁盘读取完毕后会以信号或者中断的形式出现。但是这种方法,从一个请求的工作状态切换到另外一个状态时候,需要显式的保存或者重新装入相应的计算状态。这样看来,这种异步形式也相当于一种线程,但是我们可以看成协程。在这里,每一个计算都有一个被保存的状态,存在一个会发生且使得相关状态发生改变的事件集合,比如中断,这类设计为有限状态机。

线程模型

  • 基本

进程模型基于两种独立的概念:资源分组处理与执行,进程会将相关的资源集中在一起,进程有存放程序正文和数据以及其他资源的地址空间。

进程有一个控制线程,线程中有一个程序计数器,记录接着要执行哪条指令,同时有寄存器,用来保存线程当前的工作变量,还有一个栈,用来记录执行历史,每一帧保存了一个已经调用但是还没有从中返回的过程。所以可以看出,进程用于将资源集中在一起,而线程是在CPU上被调度的实体。

线程给进程模型增加了一项内容,在同一个进程中,可以有多个线程执行,在同一个进程中并行运行多个线程,是在一个机器上并行运行多个进程的模拟。多线程中,会共享一个进程的资源和地址空间,多进程中,共享物理内存,磁盘和其他资源。

除了共享地址空间外,所有线程还共享一个打开文件集,子进程,报警以及相关信号,全局变量。

一个线程打开了一个文件,那么其他线程也可以对该文件进行读写,资源管理的单位是进程。线程实现的是,共享一组资源的多个线程的执行能力,这样多个线程可以为完成一个任务而共同工作。

所以多个线程是可以用一个数据库连接的,但是必须加锁使用。

每个线程有自己的堆栈,每个线程的堆栈有一帧,供各个被调用但是还没有从中返回的过程使用。在该帧中存放了相应过程的局部变量以及过程调用完成之后使用的返回地址。每个线程会有不同的过程,从而有一个不同的执行历史,所以需要自己的堆栈。

  • 创建

进程通常会从当前的单个线程开始,这个线程可以调用一个库函数(thread_create)来创建新线程,并且一般所有线程都是平等的,而不像进程那种,创建线程后会返回一个线程标识符,标识符就是新线程的名字。

  • 退出

thread_exit退出,或者调用thread_join等待另外一个线程退出,阻塞一直到那个线程退出。

  • thread_yield

允许线程自动放弃CPU从而让另一个线程运行,因为不同于进程,线程库没法使用时钟中断来强制线程让出CPU。所以得随时间推移自动交出CPU。

加入线程后,在fork中可能出现问题,fork后子进程也包含父进程的线程。还有如果一个线程关闭了文件,另一个线程还在执行。所以多线程程序需要更多的努力。

POSIX线程

线程包叫做Pthread。每个线程都含有一个标识符,一组寄存器(包括程序计数器)和一组存储在结构中的属性,这些属性包含堆栈大小,调度参数以及线程需要的其他项目。

用户空间实现线程

将整个线程包放在用户空间中,从内核角度考虑,就是按单线程进程的方式来管理,优点是可以在不支持线程的操作系统上实现,所以可以使用函数库来实现线程。

这些线程会在一个进程中运行,进程中保存了相应的线程表,用来跟踪该进程中的线程。线程表仅仅记录各个线程的属性,如每个线程的程序计数器,堆栈指针,寄存器和状态等。当一个线程转换到就绪状态或者阻塞状态时候,在该线程表中存放重新启动该线程所需要的信息。

  • 优点

如果进行本地阻塞,也就是说和系统没有太大关系,比如等待进程中的另外一个线程完成某项工作。首先会调用一个运行时系统的过程,检查该线程是否必须进入阻塞状态,如果是,则在线程表中保存该线程的寄存器,查看表中可运行的就绪线程,把新线程的保存值重新装入机器的寄存器中。只要堆栈指针和程序计数器一被切换,新线程就开始运行。这样进行线程切换要比陷入内核至少快一个数量级。

线程完成运行时候,比如调用thread_yield的时候,首先将该线程的信息保存在线程表中,然后调用线程调度程序来选择另一个要运行的线程,因为保存该线程状态和调度程序都只是本地过程,所以启动它们要比内核调用效率要高,不需要陷阱,不需要上下文切换,不需要对内存高速缓存进行刷新。

另外,用户级线程允许每个进程有自己的调度算法,并且用户线程具有好的可扩展性,因为内核线程需要特定的空间来存储表格和堆栈,当线程数量特别大的时候,就有问题。

综上,可以看出,用户级线程的优点的核心在于其不需要进行内核态的转换,所有过程本地进行,不论是切换,还是保存的数据位置,所以可以保证其高效。

  • 缺点

如何实现阻塞系统调用,如果一个线程执行阻塞系统调用,系统因为并不知道这个线程的存在,所以会将整个进程阻塞!这样就会影响该进程的其他线程的运行。所以需要允许每个线程使用阻塞调用,但是还要避免被阻塞的线程影响其他线程。

一种解决方案是,如果某个调用会阻塞,就提前通知,比如系统调用select允许调用者通知预期的read是否会阻塞。所以就可以先进行select调用,只有在安全的情形下才进行read掉哟哦那个,如果read调用会被阻塞,有关调用就不进行,代之运行另外一个线程。到下次有关运行系统取得控制权后,再去检查read调用是否安全。

另一个问题是页面故障问题,因为程序不是一次性在内存中,如果某个程序调用或者跳转到了一条不在内存的指令上,就会发生页面故障。就需要去磁盘读取,这样就会阻塞相关进程直到磁盘I/O完成为止,因为内核不知道有线程的存在。

另外一个问题是,在一个单独的进程内部,没有时钟中断,所以不可能用轮转调度方式调度进程。

但是程序员在经常发生线程阻塞的应用中才希望使用多个线程。就得持续进行select系统调用,以便检查read系统调用是否安全。

内核中实现线程

现在在内核中记录系统中所有线程的线程表,某个线程希望创建一个新线程或者撤销一个已有线程时候,进行一个系统调用,这个系统调用通过对线程表的更新完成线程创建和撤销工作。与此同时,内核还维护了传统的进程表,以便跟踪进程的状态。

所有能够阻塞线程的调用都以系统调用的形式实现,一个线程阻塞的时候,内核根据其选择,可以运行同一个进程的另一个线程或者另一个进程中的线程。

回收线程的时候,将其标志为不可运行,但是内核数据结构没有受到影响,在创建一个线程的时候,就重新启动某个旧线程。所以节省了一些开销。

内核级线程的主要缺点是系统调用的代价较大,如果有许多创建和终止,就会有很大的开销。线程切换由内核控制,切换的时候,要从用户态进入内核态,切换完毕要从内核态返回用户态

还有以下这些问题,比如多线程进程创建新进程的线程管理。以及信号的处理,因为信号是发送给进程而不是线程的。内核级线程的速度慢。

混合实现

使用内核级线程,然后将用户级线程与某些或者全部内核线程多路复用起来。内核只识别内核级线程,并对其进行调度,一些内核级线程会被多个用户级线程多路复用。每个内核级线程有一个可以轮流使用的用户级线程集合。

线程创建完全在用户空间中完成,线程的调度和同步也在应用程序中进行. 一个应用程序中的多个用户级线程被映射到一些(小于或等于用户级线程的数目)内核级线程上。

这样即使某些线程阻塞掉,也会切换到另外一个内核线程对应的用户线程里。

调度程序激活机制

调度程序激活机制是模拟内核线程的功能,如果用户线程从事某种系统调用是安全的,那么就不应该进行专门的非阻塞调用或者进行提前性检查。如果线程阻塞在某个系统调用或者页面故障上,只要同一个进程有任何就绪的线程,就应该有可能运行其他的线程。

使用调度程序激活机制时,内核给每个进程安排一定数量的虚拟处理器,让用户空间的运行时系统将线程分配到处理器上。当内核了解到一个线程被阻塞之后,内核通知该进程的运行时系统,并且在堆栈中以参数形式传递有问题的线程编号和所发生事件的描述。这个机制就是上行调用。

这样激活之后,运行时系统重新调度线程,把当前线程标记为阻塞并从就绪表中取出另一个线程,设置其寄存器,然后再启动之。等到内核直到原来线程又可以运行时候,内核再次上行调用运行时系统,通知这个事件,运行时系统根据情况重启被阻塞的线程,或者放入就绪表稍后运行。

上行调用是违反分层次系统内在结构的概念,通常,n层提供n+1层可调用的特定服务,但是n层不能调用n+1层中的过程。

弹出式线程

分布式系统中经常使用线程。在处理服务请求中,传统的方法是将进程或者线程阻塞在一个receive系统调用上,瞪大系统到来,当消息到达时候,系统调用接收消息,并且打开消息检查内容,然后处理。

弹出式线程中,一个消息的到达导致系统创建一个处理该消息的线程,这个线程是全新的,每个必须存储的寄存器,堆栈这些内容,可以快速创建这类线程,对该线程指定要处理的消息。

可以在用户级创建这种线程,也可以在内核级,内核级更方便,可以访问所有的I/O设备,但是危害也更大。

使单线程代码多线程化

  • 全局变量的问题

有可能在一个线程中写入会影响到另一个线程的读取。一种解决方案是给每个线程赋予其私有的全局变量。

  • 许多库过程并不是可重入的,在前面的调用没有结束之前,不能进行第二次调用。因为可能库内部使用一个固定的缓冲区进行消息组合。