操作系统 空转 线程

2020-02-20 21:37:12 qq_41669298 阅读数 75

进程和线程

进程

什么是进程

  在操作系统中,操作系统需要对各种资源进行管理,大概可以分为以下几类:内存,文件,磁盘,进程。所谓进程就是操作系统有序管理应用程序的执行的方式,来保证以下几点:
  1、所有资源对多个应用程序是可用的。
  2、物理处理器在多个应用程序中切换,保证所有程序都在执行中。
  3、处理器和I/O设备都能得到充分的利用。
  因此所有现代操作系统都依赖于一个模型,在该模型中,一个应用程序对应一个或多个进程。进程的定义有以下几条:
  1、一个正在执行的程序。
  2、一个正在计算机上执行的程序实例。
  3、能分配给处理器并由处理器执行的实体。
  4、由一组执行的指令、一个当前状态和一组相关的系统资源表征的活动单元。
  简单来说什么是进程,进程就是正在执行中的程序。而在操作系统中,操作系统为了更好的描述一个进程,于是将进程视为一些元素组成的实体,而其中最重要的两个元素是程序代码数据集。一般来说一个程序有了程序代码和数据集就可以顺利执行了,但是操作系统说还不够,为了满足操作系统对进程的控制,例如调度,中断,执行等操作,操作系统将每个进程描述为一个叫做进程控制块(PCB) 的数据结构,在PCB中存储着操作系统对控制一个进程所需要的全部信息,可以根据PCB找到程序代码,找到程序的数据,程序获得的资源等等。所以一个进程对于操作系统来说就是一个PCB。PCB中所存储的信息我们在下文中有详细介绍。
  知道了操作系统是通过PCB管理进程的后接下来讨论进程的状态。

进程状态

两状态模型

  在多道操作系统中,我们假设现在的处理器都是单核的即同时只能有一个进程正在处理器中执行,但是操作系统为了让用户看上去所有进程都在“同时”运行于是他在操作系统中设置了时间片,即一个进程可以连续执行的最大时间,并且按照调度算法快速在不同进程间进行切换执行,执行中的进程状态为运行态,而未执行的则成为非运行态,其中关系如下图。
进程状态
  同时我们可以把非运行态的进程组织到队列中,每次切换进程从队列中调出一个进程开始运行,而切换下来的进程要么重新加入队列要么执行完毕退出,如下图。
进程状态
  这里提一下可能导致创建新进程的事件和可能导致进程退出的事件。
  进程创建由以下4种事件触发:
  1、新的批处理作业。新的批处理作业进入操作系统肯定会创建新的进程来执行批处理作业。
  2、用户登录。用户登录往往也会创建新进程来执行用户指令,之所以使用进程是为了将用户与操作系统隔离,一个用户指令的崩溃不会影响到其他用户乃至操作系统。
  3、为提供服务由操作系统创建。有时操作系统为了提供一个服务也会创建新的进程,例如用户进程请求打印一个文件,系统可以创建一个管理打印的进程,进而使请求进程可以继续执行。
  4、由现有进程派生。当现有进程引发另一个进程的创建时,操作系统也会创建新的进程,这就是进程派生,这往往很有用,派生出的进程可以帮助主进程处理数据,组织数据等等。
  进程的终止由以下14种事件触发:
  1、正常完成。正常结束运行。
  2、超过时限。进程运行超过规定的时限。
  3、无内存可用。系统无法满足进程需要的内存。
  4、超出范围。进程试图访问非法的内存单元。
  5、保护错误。进程试图使用不允许使用的资源或文件。
  6、算术错误。进程试图进行被禁止的运算。
  7、时间超出。进程等待某一事件发生的时间超过了规定的时间。
  8、I/O失败。在输入输出期间发生错误。
  9、无效指令。进程试图执行一个不存在的指令。
  10、特权指令。进程试图使用为操作系统保留的指令。
  11、数据误用。错误类型或未初始化的一块数据。
  12、操作员或操作系统干涉。操作员或操作系统终止进程。
  13、父进程终止。在某些操作系统中,父进程终止时操作系统会自动终止该进程的所有子进程。
  14、父进程请求。父进程要求终止其子进程。

五状态模型

  如果所有进程都做好了准备,操作系统会从未运行队列中以轮转的方式调度每个进程。但是这里有个问题,如果并非所有进程都做好了准备呢?也许未运行的进程中有些进程正在等待某一事件的发生,也就是处于阻塞,因此单纯的对所有未运行的进程进行轮转是不科学的,应该对所有已经就绪的进程进行调度。解决这种问题的最好方法就是将未执行进程队列拆分为两个队列分别是就绪队列和阻塞队列,由此进程的状态由2状态变为了3状态,此外还要增加新建退出态,这十分有必要。改进后的状态模型如下图所示。
进程状态
  运行态:进程正在执行。
  就绪态:进程做好了准备,随时接收调度。
  阻塞态:进程在等待某些事件的发生,在事件发生前不能执行,如I/O操作。
  新建态:刚刚新建的进程,操作系统还未将其加载至内存,通常是PCB已经创建但是还并未加载到内存中的新程序。
  退出态:操作系统从可执行进程组中释放的进程。
  新建态与推出态十分有必要。在一个进程被新建时它并非绝对会被调入内存,通常是分两步,首先创建该进程的PCB,并与之关联,但是此时可能面临内存不足或者操作系统限制了最大进程数导致这个进程还无法被调入进程,因此该进程被暂时留在新建态,在这个状态的进程PCB已经创建并且加载进内存,但是进程的代码和数据往往还留在外村中等待加载。
  退出态也和新建态同理。当进程因为某些人原因要被终止时,此时并不直接将其调出内存,首先操作系统会停止执行该进程的代码,但是暂时让其留在内存中,因为某些辅助程序或是支持程序会来记录该进程相关数据和信息,此时进程停留在退出态。等相关程序收集完所需信息后,再将其所有数据从内存中移除。
  关于阻塞,就绪和运行三种状态的转换更为普遍和便于理解。操作系统从就绪队列中调度某个进程进入运行态运行,当时间片结束后操作系统将其放回就绪态执行其他进程,如果在执行期间进程必须等待某些事件,便将其放入阻塞态,然后调度其他进程执行。当该进程等待的事件完成后操作系统则将其放回就绪态等待调度。
  但是此时又有一个问题,如果所有阻塞进程放在同一个阻塞队列中,当一个事件完成后操作系统不得不扫描整个队列找到那些等待该事件的进程然后将其放进就绪队列中,这样的效率十分低下,因此通常是为每一个事件创建一个阻塞队列。同理当按照优先级进行调度时,也会将优先级相同的进程放进一个就绪队列,避免扫描等低效的做法,这是典型的用空间换时间的做法。
进程状态

七状态模型

  在介绍七状态模型前,我们思考一个问题,三个基本状态(就绪,运行和阻塞)的所有进程都必须存储在内存中,此时就可能出现一种情况,即所有进程都处于阻塞态,没有就绪状态的进程,此时又开始了处理器的空转,处理器没办法执行进程只能开始等待进程从阻塞态恢复就绪态,并且加入此时又有新的进程处于新建态,由于内存不足,处于新建态的进程也没办法进入内存无法执行,这是一个十分致命的处理器空转问题,解决这个问题有两个方法:扩大内存,很显然成本太高了;将阻塞态的进程暂时调出内存放回磁盘,来让新建态的进程有足够内存进入就绪态开始处理器的调度和运行。
  但是在将一个阻塞态进程挂起后,操作系统可以选择接纳一个新建态进程进入就绪队列,也可以选择将一个之前挂起的进程恢复就绪态,并且为了减少操作系统的负载操作系统更倾向于后者。但是处于挂起的进程也可能还并未接触阻塞,将一个阻塞进程放回内存没有任何意义,于是更好的方法是将挂起区分为两个状态即就绪/挂起态阻塞/挂起态,这样每次操作系统就只需要考虑是否应该把进程从就绪/挂起态换回就绪态即可。完整的七状态模型如下:
进程状态
  阻塞/挂起态:进程在外存中并等待一个事件。
  就绪/挂起态:进程在外存中,但只要载入内存即可开始运行。
  并且操作系统允许进程从就绪变为就绪/挂起态,或从阻塞/挂起态变更为阻塞态,只是这样做的意义不大,因此并不会这样做。
  导致进程被挂起的事件有以下几种:
  1、交换。为了释放内存空间。
  2、其他OS原因。操作系统可能会挂起后台进程或者工具进程,或挂起可能会导致问题的进程。
  3、交互式用户请求。用户希望挂起一个进程来进行调试。
  4、定时。进程可被周期性的执行,并在等待下一个时间间隔时挂起。
  5、父进程请求。父进程可能希望挂起后代进程的执行,以检查或修改挂起的进程。

进程描述

进程在操作系统中的描述方式

  操作系统可以管理计算机内的任何资源,包括内存、设备、文件和进程但是操作系统是如何管理的呢?对于操作系统来说,所有的资源都被组织成对应的数据结构,内存对应内存表,设备对应设备表,文件对应文件表,进程自然也有进程表,如下图。接下来我们将详细介绍操作系统如何描述操作系统中的所有进程,也就是进程表的结构。
进程描述
  如上图所示,进程表中存放着一个一个进程,而每个进程项都指向一个进程映像,什么是进程映像呢?我们说一个进程最基本的元素是用户代码以及元素集,初次之外还有若干操作系统控制进程所需的信息,这些信息都存放在进程映像中,并且还有一个进程用于存储临时数据的栈,因此进程映像中的典型元素可以概括如下:
  1、用户数据。用户空间中的可修改部分,包括程序数据、用户栈区域和可修改的程序。
  2、用户程序。待执行的程序。
  3、栈。每个进程有一个或多个后进先出栈,栈用于保存参数、过程调用地址和系统调用地址。
  4、进程描述块。操作系统控制进程所需的数据。
  有了以上信息就有了一个进程调度,运行所需的全部数据,这些数据在内存中有可能是连续的也有可能是不连续的,这根据操作系统内存管理的方式来决定,但是但从操作系统描述管理进程方式来看,操作系统通过在内存中的主进程表,每一表项都至少包含一个指向进程映像的指针,通过进程表操作系统可以找到控制进程所需的全部数据。

进程属性

  我们知道了操作系统通过进程表和进程映像描述进程,进程映像中的用户数据和用户程序都是根据用户所写的程序而定的,栈也只是用来保存参数调用地址所用的临时储存空间,但是其中我们要尤为重要介绍PCB(进程描述块)。正如之前所说进程描述块中储存了操作系统控制进程所需的一切信息,对于操作系统来说拿到进程控制块就可以控制进程进行调度等操作,那么进程控制块中到底存放了进程哪些信息呢?
  不同操作系统的PCB中组织的信息是不同的,但是PCB中所有操作系统都需要的共用基础信息一共8种:
  1、标识符:PID,与进程相关的唯一标识符。
  2、状态:进程状态,状态的划分是接下来介绍的重点。
  3、优先级:与进程调度有关的优先级。
  4、程序计数器:程序中即将执行的下一条指令的地址。
  5、上下文数据:进程执行时处理器的寄存器中的数据。
  6、内存指针:包括程序代码及相关数据的指针,以及与其他进程共享内存的指针。
  7、I/O状态信息:进程的I/O请求,分配给进程的I/O设备和进程使用文件
  8、记账信息:包括处理器时间综合、使用的时钟数综合、时间限制、记帐号等。
  这些信息一共可以分为三类进程标识信息处理器状态信息进程控制信息。进程标识信息典型的就是标识符,他是一个操作系统中唯一标识一个进程的基本索引。处理器状态信息由处理器寄存器的内容组成,中断进程时,必须保存寄存器中的所有信息,以便进程恢复时使用,这些信息就保存在PCB中,典型的有上下文数据。进程控制信息是操作系统控制和协调各种活动进程所需的额外信息,例如进程优先级。
  根据以上的介绍,进程映像在虚存中的结构基本如下图所示,但是具体情况还得视操作系统的具体管理方案而定。
进程描述

进程控制

执行模式

  操作系统必须保证自己的安全性,因此再让用户进程运行时并不能将所有的权限交给用户,这样操作系统很可能会被进程搞到崩溃,最好的方式是操作系统将一些特权指令不进行公开,用户进程不能直接执行这些指令,但是操作系统允许进程发起使用特权指令的请求,然后再有操作系统自己代替用户执行指令,这样可以大大增强操作系统的健壮性,同时内存也并不会让用户进程都可以访问到,如果修改了操作系统即内核可能会发生致命错误,于是这中间操作系统加入了种种限制,先从一个进程的执行上来说,操作系统将其分为了两种模式用户模式(用户态/目态)内核模式(内核态/管态)
  用户进程默认是在用户模式下运行,在用户模式下进程的权限受到控制,而如果发生了一些特殊事件,例如请求系统调用模式会从用户模式转换为内核模式。说白了用户模式即处理器在执行用户代码,内核模式即处理器目前在执行内核代码。那么这样有出现两个问题,处理器如何知道它正在什么模式下执行?一般情况下,程序转太子中通常存在一个只是执行模式的位,该位会因模式的变化而变化,也就是说在处理器的一个寄存器中存储了当前处理器处于什么模式下的信息。例如Intel Itanium处理器中就有一个包含2位CPL(当前特权级别)字段的处理器状态寄存器用于存储模式信息。

进程创建

  操作系统在创建一个进程的时候会进行哪些工作呢?当操作系统决定创建一个进程时会执行以下操作:
  1、为新进程分配一个唯一的进程描述符。
  2、为进程分配空间。
  3、初始化PCB。
  4、设置正确的链接。例如将进程放到就绪队列中,而就绪队列是一个链表,此时就需要在数据结构上进行连接。
  5、创建或扩充其他数据结构。例如创建账单和评估性能。

进程切换和模式切换

进程切换

  进程切换在什么时候发生呢?理论上在任何时刻只要操作系统拿到控制权就可以进行进程切换,那么什么时候操作系统会重新拿到控制权呢?
  这里首先考虑中断的情况,而中断又可分为两种:中断陷阱。中断一般是与当前正运行进程无关的某种外部事件相关,例如完成了一次I/O操作,中断处理器完成一些基本的辅助操作后将控制权转给与已发生的终端相关的操作系统历程,简单来说中断的发生属于正常的事件,不过是操作系统暂时停止执行当前进程转为处理另外一件更加紧急的事情。例如以下三种中断:
  1、时钟中断。当前进程时间片到期,转为从就绪队列中调度新的进程开始运行。
  2、I/O中断。某一I/O完成,操作系统判断是否有正在等待该I/O的进程,如果有将其放回就绪态,随后操作系统根据调度算法调度合适的进程继续运行。
  3、缺页中断。处理器遇到一个引用不存在内存中的虚存地址时,此时会发生缺页中断,然后操作系统要根据算法将访问的页调入内存,这块的处理与操作系统对内存管理有很大关系。
  除了中断,陷阱也有可能会导致进程状态的切换。所谓陷阱就是异常或者错误。即发生在程序内部的不可预期的非法错误。如果错误致命则将当前进程改为退出态,不致命时操作系统的行为决定于操作系统的设计,有可能是简单的通知用户,也有可能是尝试恢复。
  还有一种可能会导致进程切换的事件,就是系统调用。当用户进程发起一个特权指令(系统调用)时,操作系统会将当前用户进程设置为阻塞态,然后会调用系统例程执行系统调用指令,当执行完毕会在此调度用户进程开始执行。
  综上所述,可能造成进程状态切换的事件有三种中断,陷阱(异常),系统调用

模式切换

  操作系统为了安全设置了不同的执行模式,那么操作系统何时进行模式切换呢?我们知道内核模式就是处理器在执行内核中的系统代码,那么不难得出,只要发生状态转换的事件一定会造成模式转换。例如中断,不管时哪一种中断,都少不了操作系统要根据调度算法重新调度进程开始运行,更不用说缺页中断中操作系统还需要进入内核状态执行内存置换算法换页等等;异常也是需要操作系统判断如何进行下一步处理也需要进行模式切换;系统调用就是在执行系统历程,更需要模式的切换,因此我们可以得出进程模式切换的基本事件就是中断,陷阱(异常),系统调用
  但是要注意的时,并非模式切换一定会导致运行态进程切换,例如在中断后操作系统根据调度算法决定继续执行当前用户进程,那么当前用户进程就完全不需要改变状态,相比切换运行态进程单单切换模式,操作系统所要做的操作可要少多了。所以进程切换一定会导致模式切换,但进程模式切换并不一定会发生进程状态切换。

操作系统的组织形式

  我们之前的介绍都基于操作系统是在所有进程独立外的一个大型程序,是一组进程,那么操作系统到底是进程么?如果是进程的话又要怎么控制它?
  以下是几种操作系统内核的设计方式。

无进程内核

  这种设计方式在许多老操作系统中都十分常用,是一种相当传统的设计方式。这种设计方式的原则是将操作系统视为独立于每个用户进程外执行的一个大的系统内核。我们每次要执行操作系统代码例如发生中断,陷阱,系统调用时,都需要进行代码及及数据的切换,将用户代码及数据暂时保存然后执行操作系统内核代码,执行完毕后恢复调度用户进程或是调度其他进程。在这种设计方法下,进程这一概念仅适用于用户程序,操作系统代码则是在内核模式下单独运行的实体。下图为这种设计方法的示意图:
组成形式

在用户进程内运行

  较小的计算机操作系统通常采用这种设计方式,这种方式是将系统内核代码放到每个进程虚存中的共享区,这样做的好处是如果要执行系统代码不需要像无进程内核那样切换代码及数据以切换系统历程,这种方式仍然是在每个用户及进程内部执行操作系统代码,不需要切换进程,只用在同一进程中切换模式即可,所带来的系统开销更小,更加快捷。并且在一个进程内用户程序和操作系统程序都可执行,而在不同用户进程中执行的操作系统程序是相同的,这也是为什么要将系统内核放到共享地址空间的原因,在这种方式下一个进程在虚存中的映像如下:
组成形式
  这种设计方式的示意图如下:
组成形式

基于进程的操作系统

  这种设计方式是把操作系统作为一组系统进程来实现。和其他方法一样同样是在内核模式下运行系统代码,但是在这种情况下是吧内核功能都组织为独立的进程,但同时往往也将一些进行进程切换工作的代码独立出来。这种方式的好处是使用模块化系统设计的原理,可以将一些操作系统功能作为独立进程来实现,同时这种方式在多处理或多继环境中很有用。这种设计方式的示意图如下:
组成形式

线程

线程和进程

  现代的大多数操作系统都支持线程的使用,因为进程所具有的两个特点资源所有权调度,但是操作系统更希望将这两个特点分开进行处理,于是便诞生了线程,我们将进程视为资源分配的基本单位,将线程视为处理器调度的基本单位,线程也可以视为一个轻量级进程

多线程

  多线程是指操作系统允许在单个进程内有多个并发执行路径,一个并发执行路径又被成为一个线程。早期各个版本的操作系统他们支持多用户进程,即允许一个任务内拥有多个进程进行并发处理,但是每个进程内部只允许有一条执行路径,也就是只允许拥有一个线程,而如今的现代操作系统中绝大多数操作系统支持多线程方法,其中的差别可用下图表示:
多线程
  在多线程的基础上程序并发将会更容易实现,因为线程是一个轻量级进程因此切换和调度的消耗会更少,并且同一进程内的线程之间共享虚拟地址空间,因此通讯会更加方便。在多线程环境中,进程定义为资源分配基本单位和一个保护单位,一个进程内部有:
  1、容纳进程映像的地址空间。
  2、对处理器、其他进程(进程间通讯)、文件和I/O资源(设备和通道)的受保护访问。
  一个进程中可能有一个或多个进程,每个线程都有:
  1、一个线程执行状态。
  2、未运行时保存的线程上下文。
  3、一个执行栈。
  4、每个线程用于局部变量的一些静态存储空间。
  5、与线程内其他线程共享的内存和资源的访问。
  在多线程环境下,每个进程依然有自己的进程控制块以及进程映像,但是进程中的每个线程也拥有属于自己的独立的栈以及线程控制块,控制块中存储着线程状态,调度优先级,上下文数据等,这些是每个线程独立的信息,除此之外进程内的代码段用户数据段包括进程控制块中的信息在进程内各个线程间共享,因此才可以做到进程内各个线程之间都驻留在同一地址空间中可以做到除独立信息外的数据及代码共享。但是每个操作系统对多线程环境的实现方法都不尽相同,具体实现方法视具体环境而定,但是都应该满足进程和线程各自的基本特点。模式如下:
多线程

进程和线程之间的区别

  这是十分常见的问题,在此做同一归纳和梳理:
  1、进程是资源分配的基本单位,线程是处理器调度的基本单位。
  2、同一进程内线程共享进程状态和资源,例如数据段,代码段,I/O信息等。但是每个线程内也有独立的数据,每个线程都拥有属于自己的栈,线程属性信息存在线程控制块中,例如上下文数据,线程状态,调度信息等。
  3、线程是轻量级进程,因此创建和销毁所消耗的系统资源更少,更快。
  4、同一进程内线程切换所消耗的资源相比进程切换更少且更快。
  5、同一进程内线程共享大部分数据因此通信起来更加方便,无需借助内核。

线程分类

  在讨论线程分类前我们先思考这么几个问题:如果进程中的一个线程因为请求资源而被阻塞那么这整个进程是否应该被阻塞?在多核操作系统中统一进程中的线程是否应该允许并行执行?
  根据以上两个特点,我们将线程可以分为内核级线程(KLT)用户级线程(ULT)。它们二者之间各有优点各有特色,当然这都却决于操作系统的具体实现方式的基础上。示意图如下:
线程分类

用户级线程

  在纯ULT操作系统上,对于操作系统来说线程是不可见的,操作系统依然只负责维护进程的相关控制和管理工作,而进程内线程之间的调度以及管理包括通信全部由应用程序自行完成,内核并意识不到现成的存在。用户可以使用线程库来将任何一个程序设计成多线程的,并用线程库完成多线程的管理和控制。
  在这样的实现方式下由于操作系统并无法意识到线程的存在所以除非用户自己主动调度线程,操作系统并无法完成进程内线程间的切换,并且如果此时进程内一个线程发生阻塞,例如进行了一次系统调用,系统会将整个进程置为阻塞态,包括进程内其他线程也会一起阻塞,这是十分不灵活的设计。
  例如假设进程B中有着线程1线程2两个线程,它们的状态如(a)所示,现在有可能发生如下情况:
  1、线程2进行了一次系统调用,由于操作系统无视线程的存在,因此它认为是进程B进行了系统调用,因此将整个进程B进行了阻塞。在此之后线程2依然处于运行态,但是对于操作系统来说线程2实际上并不处于运行态。直到进程B取消阻塞,线程2继续恢复运行。此时状态如(b)所示。
  2、时钟中断使整个进程B停止运行态转为就绪态,此时调度其他进程执行,此时将进程B置为就绪态,线程2依然处于运行态,直到下次再此调度进程B恢复线程2的运行。如图©所示。
  3、线程2运行到需要线程1执行某些动作的一个点,应用程序内部将线程2置于阻塞态,开始运行线程1。如图(d)所示。
用户级线程
  使用ULT有以下优点:
  1、所有线程管理数据结构都在进程内的用户地址空间中,线程之间调度不需要内核的参与,因此就不需要模式的转换,效率更高。
  2、不同的调度程序可以设置不同的线程调度算法,灵活性高。
  3、ULT可在任何操作系统上运行,不需要对系统内核代码修改以支持ULT。
  但ULT也有着以下缺点:
  1、一个线程的进行了系统调用导致阻塞会阻塞整个进程,影响其他所有线程。
  2、多线程应用程序无法利用多处理技术,即内核一次只能把一个进程交给处理器,因此一个进程中只有一个线程可以执行,这相当于再一个进程内实现了多道程序设计,并无法使一个进程内的线程并行执行。
  当然以上两种缺点是有办法弥补的。如果希望程序并行执行就将程序设计为多进程而非多线程;系统调用使进程阻塞可以使用套管技术解决,即将一个可能会产生阻塞的系统调用转换为一个非阻塞系统调用,当然这样的处理更加繁琐一些。

内核级线程

  内核级进程就是将进程的管理全权交给内核来处理,用户使用内核提供的API来控制线程,Windows就是使用这样的方式来实现线程的。
  KLT的优点就是ULT的缺点,KLT的缺点就是ULT的优点。其中最大的缺点就是每次线程之间的切换都需要内核模式的切换,消耗更大,但是可以肯定的是哪怕KLT线程的消耗再大也是远远小于进程之间切换的,因此为了方便管理目前常用的操作系统都是基于内核级线程来实现的。

混合方法

  在混合方法中,内核级线程会被映射到一些由内核管理的内核级线程上,内核级线程是小于或等于用户级线程的。当用户级线程和内核级线程相等时此时等价于使用了纯KLT方式。
  这种混合方法在设计合理时可以完美结合KLT和ULT的优点,并克服它们的缺点。目前Solaris就是使用了这种混合线程的方法。

Linux的进程和线程管理

进程管理

  Linux属于类UNIX操作系统,实现的原理与UNIX进程的实现方法类似,其实大部分的操作系统都要遵循系统设计的基本原理,但是实现细节上会有所不同。在Linux上进程状态转换如下图:
Linux进程管理
  在Linux系统实现中最大的变化就是将阻塞态变为了可中断不可中断两个状态,并且加入了停止态
  1、可中断:这是一个阻塞态,进程正在等待一个事件的结束。
  2、不可中断:这是一个阻塞态,与可中断的区别是,此时进程正在等待一个硬件条件,因此屏蔽任何信号。
  3、停止:进程收到信号要求被其他进程暂停执行,并且只能由另一个进程的主动动作恢复运行。

线程管理

  Linux使用一种十分特殊的线程处理方式,它内核中并没有独立的线程控制块,Linux选择使用PCB模拟实现线程,因此在Linux中PCB其实就相当于是一个线程。
  那么Linux又是如何实现进程间数据独立线程间数据共享的呢?Linux将用户级线程映射到内核级进程上,组成一个用户级进程的用户级线程则映射到共享一个组ID的多个Linux内核级进程上,使得同一个组内部的进程共享文件和内存等资源,就像一个进程内部的线程共享资源一样。也就是说用进程模拟实现线程,通过给进程分组的方式来实现数据的共享和独立。
  同时Linux在内部又通过命名空间来管理进程的数据。命名空间可使一个进程拥有与其他不同命名空间的进程不同的系统视图,因此可以获得不同资源。典型的命名空间有:
  1、Mount命名空间。为进程提供文件系统层次的特定试图。
  2、UTS命名空间。与系统配置有关。
  3、IPC命名空间。隔离进程间IPC资源,如信号量。
  4、PID命名空间。隔离进程ID空间。
  5、网络命名空间。隔离与网络相关的系统资源。
  6、用户命名空间。提供容器使其与父进程隔离。

2018-12-30 13:48:10 Ha1f_Awake 阅读数 132

主要内容


 

引入线程的原因

进程是为了提高CPU的执行效率,减少因为程序等待带来的CPU空转,以及其他计算机软、硬件资源的浪费而提出来的,是为了完成用户任务所需要的程序的一次执行过程以及为其分配资源的一个基本单位。(可回顾“进程的基本属性”)

但是,创建撤销切换进程耗费较大的系统开销和占用较多的资源。开销越大,服务器能支持和处理的用户访问请求就越少。

为了减少创建、撤销进程以及进程上下文切换的开销,提高执行效率和节省资源,人们开始在操作系统中引入一个概念——线程

 

线程的基本概念

线程是进程的一部分。因此线程有时会被称为轻量级进程(light weight process)

线程的改变只代表了CPU执行过程的改变,除了CPU外,计算机内的软硬件资源的分配与线程无关,线程只能共享它所属的进程的资源。

每个线程都有自己的线程控制块(Thread Control Block,TCB),而TCB中保存的线程状态信息要比PCB少得多。

 

线程与进程的比较

1)调度

线程需要保存的信息远少于进程,所以在调度时切换的开销更小。

同一个进程中,线程的切换不引起进程的切换;若从一个进程的线程切换到另一个进程的线程,必然引起进程的切换。

 

2)并发性

在引入线程的OS中,不仅进程之间可以并发执行,线程之间也能并发执行,这使得OS具有更好的并发性,更能提高系统资源利用率和系统的吞吐量。

例如,

一个进程包括三个任务:从键盘读入数据、在后台进行拼写和语法检查、显示文字和图形。三个任务只能顺序执行。

引入线程后,一个进程包括三个线程:一个线程负责从键盘读入数据、一个线程负责在后台进行拼写和语法检查、一个线程负责显示文字和图形。三个线程可以并发执行。

上面这个例子很容易理解,正在写博客的我正体会着线程带来的福利。若没有线程,每当我键入一个拼音,就得等待后台拼写和语法检查,然后等它把可选汉字显示在屏幕上,最后我才能选择自己需要的汉字。(虽然也没有那么夸张啦,就举个栗子_(:з」∠)_)

 

3)资源

进程可以拥有资源,但线程本身并不拥有系统资源,而是仅有一点必不可少的、保证其独立运行的资源。

 

4)支持多处理机系统

在多处理机系统中,对于传统的单线程进程,不管有多少个处理机,该进程只能在一个处理机上运行。但对于多线程进程,就可以将多个线程分配到多个处理机上,使它们并行执行。

 

线程的适用范围

使用线程的最大好处是在有多个任务需要处理机处理时,减少处理机的切换时间;而且线程的创建和结束所需要的系统开销也比进程小得多。

尽管线程可以提高系统的执行效率,但并不是所有的计算机系统都适用线程。任务单一却设置线程反而会占用更多的系统资源。

由此可以推出多处理机系统网络系统分布式系统更适合适用线程

 

应用线程的例子

一个用户主机通过网络对两台远程服务器进行远程调用(RPC)以获得相应结果的执行情况。

单线程发送请求1后必须等待服务器1处理好并返回结果1后才能继续发送请求2;

多线程发送请求1后即可继续发送请求2,不必等待结果1返回。

2020-01-23 01:41:52 dh626995617 阅读数 157

前言

我们计算机上面跑的每个任务,都是操作系统层面的资源分配,从启动进程到创建线程,在核数固定的情况下,多线程并发地执行。Go协程是一个比系统线程更细粒度的资源,轻量级和易切换。
这几天看了一些相关的文章,这次尝试从操作系统到Go协程,简单聊聊它们是如何关联上的以及我个人的理解。

基本概念

操作系统(OS)

操作系统负责着底层硬件的调度,它分配CPU,内存,磁盘的资源,并且替我们分配不同线程在不同CPU核的执行,保证各个程序如预期的指令进行执行。我们提交的每个程序,最终都会转换成一系列给操作系统识别的指令。

线程(Thread)

上述经过转换的一系列指令,操作系统会通过线程来帮我们执行,本质上线程是a path of execution,一段可执行的程序路径。
线程有下面三个状态:

  • 运行中:正在执行任务,理想状态是所有线程都处于这个状态。
  • 就绪:可以随时加入运行,从就绪到运行的状态切换,叫做上下文切换,它需要一定的代价。
  • 等待就绪:需要等待资源分配或者IO(网络/设备)阻塞中,需要经过就绪才能运行,这是在用户角度最不想看到的,它成为了程序大部分的性能瓶颈。

系统调度器

调度器肩负着巨大的使命,主要在于调度CPU与线程关系,保证不会有CPU闲下来,想象一下CPU就是仓鼠笼子里面的仓鼠,调度器一旦发现有仓鼠(CPU)在打瞌睡,就推动它们到轮子(线程)上跑,一刻也不能停。毕竟CPU的运算能力是很强悍的,一毫秒的空闲都是巨大的浪费。
往细处说,仓鼠只有4只(四核),哪个轮子(线程)优先跑,取决于轮子(线程)的优先级。调度器肩负着运筹帷幄的使命,既要减少催促仓鼠跑动的延迟,同时还要保证不能有任务一直得不到执行,线程如果一直得不到仓鼠处理被称作“饥饿现象”。

任务类型

不同的任务对系统资源有不同的要求

  • I/O频繁切换
    未雨绸缪,IO指的是Input/Output,输入输出等待。这种等待资源输入/输出的job,主要瓶颈在资源是否分配到位,比如系统调用/文件IO等,其线程切换的时间远远小于IO设备/网络延迟,主要短板在于等待I/O,而不是线程上下文切换,因此可以分配较多线程,在“粮草”还没送到的这段时间,执行作战前的准备。

  • 计算密集型
    这种任务主要耗CPU,比如用一个线程计算圆周率π的第N位。如果分配大量的线程给它,系统既要保证各个线程对计算状态的共享,先不考虑会发生脏读/脏写操作,还需要频繁进行线程切换,这会造成大量时间浪费,线程每次重新唤醒都要在程序计数器(PC)寻找下一个执行指令的位置,在核数固定的情况下,分配过多线程会有事倍功半的效果。

    If your program is focused on CPU-Bound work, then context switches are going to be a performance nightmare. Since the Thead always has work to do, the context switch is stopping that work from progressing.

    当然也有例外的情况,比如Map-Reduce,指的是先拆分后聚合。当你的计算任务可以拆分成很多模块,在各个模块不同执行顺序不会影响最终结果的情况下,尝试“逐个击破”,最终再汇聚的情况,如果能合理分配任务,多线程肯定是优于单线程的。

后面尝试通过列举几个Go的简单程序,通过单/多协程处理,比对两者在不同情景的性能差异情况。

Map-Reduce:

这个例子比较简单,我们目标是累加一个等差数列

var PIXEL_ARRAY []int
//初始化一个等差数列,后面尝试将等差数列传到单协程/多协程函数进行累加
func init()  {
	for i := 1; i <= 100000; i++ {
		PIXEL_ARRAY = append(PIXEL_ARRAY, i)
	}
}

后面我们用单协程/多协程版本,区分开两个实现方式

//单协程暴力版本
func SumWithSingle(arr []int) int32 {
	var sum int32
	//遍历累加,相当暴力!
	for i := 0; i < len(arr); i++ {
		sum += int32(arr[i])
	}
	return sum
}

//多协程版本,每个协程均等计算自己分配的切片区间, gNum是起多少个协程并发处理
func SumWithMulti(arr []int, gNum int) int32 {
	var wg sync.WaitGroup
	//用于等待gNum个协程执行完
	wg.Add(gNum)

	var sum int32
	//各个任务的平均长度
	div := len(arr) / gNum
	
	//注意切割长度需要向上取整,此处非本demo侧重点,为了简单化一律使用整除切开原数组.
	//div := int(math.Ceil(float64(float64(len(arr)) / float64(gNum))))
	
	for i := 0; i < gNum; i++ {
		Left := i * div
		Right := Left + div
		if i == gNum {
			Right = len(arr)
		}
		go func() {
		    //每个协程独立的汇总
			ps := 0
			for _, value := range arr[Left:Right] {
				ps += value
			}
			//处理完累积到全局变量,由于仅有累加操作,可以用原子加实现互斥, 这里无需加锁.
			atomic.AddInt32(&sum, int32(ps))
			wg.Done()
		}()
	}

	//等待各个子协程计算完毕
	wg.Wait()
	return sum
}

性能分析

下面尝试用BenchMark分析性能,输出如下:

import "testing"

var PIXEL_ARRAY []int

func init()  {
	for i := 1; i <= 100000; i++ {
		PIXEL_ARRAY = append(PIXEL_ARRAY, i)
	}
}

func BenchmarkSumWithSingle(b *testing.B) {
	for i := 0; i < b.N; i++ {
		SumWithSingle(PIXEL_ARRAY)
	}
}

func BenchmarkSumWithMulti(b *testing.B) {
	for i := 0; i < b.N; i++ {
		SumWithMulti(PIXEL_ARRAY, 10)
	}
}

结果示例:
在4核电脑上,启动10个协程去并发处理,针对这个例子多协程是优于单协程的,符合预期。

$ GOGC=off go test -run none -bench . -benchtime 3s
goos: windows
goarch: amd64
pkg: HelloGo/basic/Multi
BenchmarkSumWithSingle-4           50000             63583 ns/op
BenchmarkSumWithMulti-4           100000             39216 ns/op
PASS
ok      HelloGo/basic/Multi     8.623s

在操作系统层面,每个线程的执行先后顺序是无法保证的。Go中,协程也是如此。拿上个例子来说,Map-ReduceMap操作是切开等差数列,分配到任务的不同Go协程执行时间是不确定的,有可能1+2+3+..., 也有可能是21+22+23+...

如果程序要控制相应线程执行的顺序,需要在操作系统的上一层,比如编程语言中加入调度指令,如++原子操作,同步锁,互斥量++,程序的性能也和锁的粒度有关系,这个相关知识可以作为以后拓展。

Go调度器的实现是基于操作系统调度这些理念实现的,后面我们尝试往更高层(用户态)走,以Go协程是如何被调度的角度来分析。


Go Scheduler

众所周知,Go调度器有下面几个主要的组件:

  • M: 工作线程, 由P创建,关联上OS线程,可以理解为M就是OS线程
  • P: 上下文,处理代码的所需资源, 创建数量与CPU核数相关,每个P会分配一个OS线程(M)
  • G: 当前go协程,如果关联上OS线程(M),代表即将或者正在执行的go代码。G可以在两个队列中找到它们,本地/全局队列,我们后面再细谈。

At any time, M goroutines need to be scheduled on N OS threads that runs on at most GOMAXPROCS numbers of processors.

P,逻辑CPU

GO的P和你的CPU核心数量有关,注意这里并不是真正CPU数量,是++逻辑CPU数量++。查看任务管理器,在4核CPU的情况下,假如说你的机器具备超线程(i7处理器),每个物理核有两个线程,那么意味着在Go程序里逻辑上有8个可用的处理器,逻辑CPU数量应该是8。即上面所提到的P
我当前机器是(i5处理器),每个核一个线程,所以下面输出的逻辑CPU(P)数量是4,这代表当启动多个线程的时候,我的机器最多可以支持并行4个系统线程,多出来的线程就是并发了。
可以通过在你的机器执行下列Go代码,看下逻辑P的数量,

package main

import (
	"fmt"
	"runtime"
)

func main() {
    // NumCPU returns the number of logical
    // CPUs usable by the current process.
    //我的机器输出 4
    fmt.Println(runtime.NumCPU())
}

每一个P会分配一个系统线程M,相当于说在这个机器的Go程序中,我有4个系统线程可以用来执行我的任务,每个都独立与其中一个核,P挂钩。

M个Go协程会分配在N个系统线程上,每个要运行的G都必须关联上P(逻辑CPU),程序可以干涉GOMAXPROCS的数量,以控制最多有多少个P可以使用。

下面用图示可能会更加直观:
Go全局与本地协程队列

Go中,每个上下文P会分配一个本地的协程队列(LRQ),叫做Local Run Queue ,一个M必须关联所需的资源P才能运行相应的G队列,正如操作系统层面上每个线程队列需要分配关联上CPU才能得到执行,类比前面的栗子,轮子(M)需要仓鼠(P)去带动,才能执行(跑G协程任务)。

如下图所示:
P-M-G
P1,P2是相对固定的,MG是可以随调度器分配选择的。


Go协程的切换

理解完上面的一些概念之后,现在我们看下Go是如何对协程进行切换,文章前面提到,假如线程创建过多,由于系统调度的不确定性,线程得不到执行,可能会长期处于饥饿。同理Go协程也会有“靠边停车”的现象,所以Go调度器需要一些的条件来触发协程切换,避免停完车就不再启动了,具体的切换本质上就是Go调度器P-M-G三者之间的断开与重连。

下列情景可能会让调度器对执行上下文,即go协程的切换做出决定:

  • go关键字,创建新的协程
  • 垃圾回收
  • 系统调用
  • 同步/互斥/Gosched()等操作

程序示例:

下面展示一个简单而且比较常见的例子,我们通过限制逻辑CPU的个数,尝试干涉Go调度器。

func main() {

    //设置最多一个P可以关联上
	runtime.GOMAXPROCS(1)
	go func() {
		for {
			//保持空转,如果没有其他函数调用,在单个处理器下该协程不会切换
			;
		}
	}()

	//尝试切换协程
	runtime.Gosched()
	println("main done.")
}

我们通过runtime.GOMAXPROCS(1)设置本次运行的逻辑CPU个数,代表可用的P只有1,意味着最多只能同时分配给一个协程去执行。如果执行这个程序,由于主协程执行了让步Gosched(),获得运行权的的Go协程会得到执行,由于该协程在死循环一直执行空语句,导致程序不会有任何输出,而且主协程得不到运行权,所以这个程序永远不会退出。

题外话:注意这里区别于上面的runtime.NumCPU(int)方法,NumCPU()是在启动时候直接调用系统方法,所以和经过GOMAXPROCS的设置之后,并不会改变NumCPU()原有的返回值,GOMAXPROCS仅是对本次运行时P数量进行限制。

协程偷窃

从Go调度器角度看,执行的协程任务有点快有的慢,既然我的职责是不能让CPU空闲,那当我有空的时候,我肯定要从别人那里偷一些任务来跑。
协程任务偷取#created by Renee French
是的,是明目张胆的偷!我觉得这张图很贴切,所以我把这张图也偷来了哈哈哈。
原文参考:Go: Work-Stealing in Go Scheduler (可能需要科学上网)

前面提到两个队列,全局/本地队列:

  • 本地队列:指的是当前上下文P关联上的Go协程队列(LRQ),本地队列,在go1.13版本每个本地协程队列的最大值是256,超过256就会放到全局队列里面去。本地队列是可以被其他P偷走的,在某些情况下,当有P发现本地的G队列空了,就会去偷其他P的本地队列,每次会从其它P的本地队列里面偷走一半的G
  • 全局队列:在其他情况,当有空闲P发现其他P的本地队列没有G可以偷的情况下,会尝试获取全局队列的G去执行。
runtime.schedule() {
    // only 1/61 of the time, check the global runnable queue for a G.
    // if not found, check the local queue.
    // if not found,
    //     try to steal from other Ps.
    //     if not, check the global runnable queue.
    //     if not found, poll network.
}

上面的注释表明了偷窃各个部分的优先级,只有1/61的时间会去检查全局队列。
优先级:本地队列 > 其他P的队列 > 全局队列

根据这张我从别处偷来的这张图,此时P2的本地队列已经是空的,所以这个时候P2即将要从P1偷走3个G
Steal from rakyll.png


总结

Go调度的思想是基于上述系统调度实现的,归根结底Go中的协程切换是P-M-G三者之间的断开与重连。所以G协程在当前线程M的切换,就像从系统角度看,线程在CPU上面的切换。

Your workload is naturally stopped and this allows a different Goroutine to leverage the same OS/hardware thread efficiently instead of letting the OS/hardware thread sit idle.

因为Go协程比线程粒度更小,更加轻量级,所以Go协程切换会比OS直接切换线程代价更小。

到这里只是浅尝辄止的梳理Go调度的情况,大家如果有兴趣深入挖掘的话可以参考郝林老师的《Go并发编程实战》,或者直接看源码,相关的Go调度策略可以在src/runtime/proc.go找到。

参考链接

Scheduling In Go : Part II - Go Scheduler
Ardanlabs素质三连:
https://www.ardanlabs.com/blog/2018/08/scheduling-in-go-part2.html
dotGo 2017 - JBD - Go’s work stealing scheduler
https://www.youtube.com/watch?v=Yx6FBsGNOp4
Go: Goroutine and Preemption
https://medium.com/a-journey-with-go/go-goroutine-and-preemption-d6bc2aa2f4b7
Go’s work-stealing scheduler
https://rakyll.org/scheduler/

2018-01-21 06:55:12 weixin_34401479 阅读数 17

tags: 操作系统, title: 操作系统第三篇【线程】


线程概述#

进入线程的目的:

程序并发执行所需付出的时空开销,为使程序能并发执行,系统必须进行以下的一系列操作:

  •  (1) 创建进程,系统在创建一个进程时,必须为它分配其所必需的、除处理机以外的所有资源,如内存空间、I/O设备,以及建立相应的PCB;
  •  (2) 撤消进程,系统在撤消进程时,又必须先对其所占有的资源执行回收操作,然后再撤消PCB;
  •  (3) 进程切换,对进程进行上下文切换时,需要保留当前进程的CPU环境,设置新选中进程的CPU环境,因而须花费不少的处理机时间。

引入线程主要是为了提高系统的执行效率,减少处理机的空转时间和调度切换(保护现场信息)的时间,以及便于系统管理。,使OS具有更好的并发性

线程——作为调度和分派的基本单位(取代进程的部分基本功能)

线程与进程

回顾一下进程的基本属性:

  • ①进程是一个可拥有资源的独立单位,
  • ②进程同时又是一个可独立调度和分派的基本单位,

线程与进程的关系:一个进程可以派生出多个线程

  • 线程有3个基本状态:执行、就绪、阻塞。
  • 线程有5种基本操作:
    • 派生、阻塞、激活、 调度、 结束
  • 线程的另一个执行特性是同步。线程中所使用的同步控制机制与进程中所使用的同步控制机制相同

线程的基础

线程有两个基本类型:

    1. 用户级线程:管理过程全部由用户程序完成,操作系统内核心只对进程进行管理。
    1. 系统级线程(核心级线程):由操作系统内核进行管理。操作系统内核给应用程序提供相应的系统调用和应用程序接口API,以使用户程序可以创建、执行以及撤消线程。

线程的属性:

  • 1)轻型实体;2)独立调度和分派的基本单位;3)可并发执行;4)共享进程资源。

如同每个进程有一个进程控制块一样,系统也为每个线程配置了一个线程控制块TCB,将所有用于控制和管理线程的信息记录在线程控制块中

如果您觉得这篇文章帮助到了您,可以给作者一点鼓励

2017-09-13 14:49:22 fhb1922702569 阅读数 600
转载自CSDN博客:http://bbs.csdn.net/topics/390631927?page=1
多线程一定多核吗?
对于操作系统和软件来说,多线程并不意味需要多核心的CPU,事实上,2005年以前,所有的个人PC都是单核心的,但是1993年问世的Windows NT就支持多线程。操作系统采用分时的办法让多个线程平分CPU时间。

多线程是不是意味着一定并发执行?‘网上各种概念太多,也太笼统,求助大神们的解答。。。
不一定,操作系统会根据线程的亲缘性、同步和锁来调度线程。比如说两个对存储加锁的线程分别读写存储,那么它们就是交替运行的。这里解释下,并发和并行不是一个概念,并发是指宏观逻辑上的,并行则是指多个CPU同时运行。如前所述,在2006年以前,PC上往往只有一个处理器,所以对于单处理器来说,是不可能并行的。

恩 还有,多核编程在有哪些经典的入门书籍呢?入门的就行
这个分为几个层次,一个是数学、算法层次的《并行计算》一类的书籍,一个是编程语言/API层面的书,比如各种Java书籍。还有一个是操作系统、硬件和计算机体系结构方面的书,比如《操作系统》《计算机体系结构》。掌握多线程编程是一回事,真正写出能充分利用并行计算的算法,并且获得很好的加速比,又是另一回事。一些数学问题,比如十进制求圆周率,已经被数学证明是不可以并行计算,只能串行计算的,但是十六进制的圆周率计算就可以并行。像这样的知识,编程的书籍是不会告诉你的。

如果进行多核编程,有没有什么软件工具或者查看cpu的使用情况呢?我去进行多核编程,想要直观查看一下多核编程与单核在cpu性能上的区别。。。
cpuz的官方网站上有一个工具,可以通过CPU内部的计数器获得CPU使用状况。另外你可以通过比较程序的执行时间来直观获得程序的加速比。注意,Windows任务管理器中的图表是不可靠的,这主要是因为多处理器环境非常复杂,比如说一个同时具有超线程技术的双核处理器,当两个满负荷的线程在两个物理内核中执行的时候,Windows任务管理器报告使用率为50%的时候,可能真实的CPU利用率在80~90%。再比如,对于NUMA节点,Windows任务管理器不能很好地反映处理器内核的亲缘关系。



转载自CSDN博客:http://blog.csdn.net/delacroix_xu/article/details/5928121

0.前言

最近发觉自己博客转帖的太多,于是决定自己写一个原创的。笔者用过MPI和C#线程池,参加过比赛,有所感受,将近一年来,对多线程编程兴趣一直不减,一直有所关注,决定写篇文章,算是对知识的总结吧。有说的不对的地方,欢迎各位大哥们指正:)

 

1.CPU发展趋势

核心数目依旧会越来越多,依据摩尔定律,由于单个核心性能提升有着严重的瓶颈问题,普通的桌面PC有望在2017年末2018年初达到24核心(或者16核32线程),我们如何来面对这突如其来的核心数目的增加?编程也要与时俱进。笔者斗胆预测,CPU各个核心之间的片内总线将会采用4路组相连:),因为全相连太过复杂,单总线又不够给力。而且应该是非对称多核处理器,可能其中会混杂几个DSP处理器或流处理器。

 

2.多线程与并行计算的区别

(1)多线程的作用不只是用作并行计算,他还有很多很有益的作用。

还在单核时代,多线程就有很广泛的应用,这时候多线程大多用于降低阻塞(意思是类似于

while(1)

{

if(flag==1)

break;

sleep(1);

}

这样的代码)带来的CPU资源闲置,注意这里没有浪费CPU资源,去掉sleep(1)就是纯浪费了。

阻塞在什么时候发生呢?一般是等待IO操作(磁盘,数据库,网络等等)。此时如果单线程,CPU会干转不干实事(与本程序无关的事情都算不干实事,因为执行其他程序对我来说没意义),效率低下(针对这个程序而言),例如一个IO操作要耗时10毫秒,CPU就会被阻塞接近10毫秒,这是何等的浪费啊!要知道CPU是数着纳秒过日子的。

所以这种耗时的IO操作就用一个线程Thread去代为执行,创建这个线程的函数(代码)部分不会被IO操作阻塞,继续干这个程序中其他的事情,而不是干等待(或者去执行其他程序)。

同样在这个单核时代,多线程的这个消除阻塞的作用还可以叫做“并发”,这和并行是有着本质的不同的。并发是“伪并行”,看似并行,而实际上还是一个CPU在执行一切事物,只是切换的太快,我们没法察觉罢了。例如基于UI的程序(俗话说就是图形界面),如果你点一个按钮触发的事件需要执行10秒钟,那么这个程序就会假死,因为程序在忙着执行,没空搭理用户的其他操作;而如果你把这个按钮触发的函数赋给一个线程,然后启动线程去执行,那么程序就不会假死,继续相应用户的其他操作。但是,随之而来的就是线程的互斥和同步、死锁等问题,详细见有关文献

现在是多核时代了,这种线程的互斥和同步问题是更加严峻的,单核时代大都算并发,多核时代真的就大为不同,为什么呢?具体细节请参考有关文献。我这里简单解释一下,以前volatile型变量的使用可以解决大部分问题,例如多个线程共同访问一个Flag标志位,如果是单核并发,基本不会出问题(P.S.在什么情况下会出问题呢?Flag有多个,或者是一个数组,这时候只能通过逻辑手段搞定这个问题了,多来几次空转无所谓,别出致命问题就行),因为CPU只有一个,同时访问这个标志位的只能有一个线程,而多核情况下就不太一样了,所以仅仅volatile不太能解决问题,这就要用到具体语言,具体环境中的“信号量”了,Mutex,Monitor,Lock等等,这些类都操作了硬件上的“关中断”,达到“原语”效果,对临界区的访问不被打断的效果,具体就不解释了,读者可以看看《现代操作系统》。

(2)并行计算还可以通过其他手段来获得,而多线程只是其中之一。

其他手段包括:多进程(这又包括共享存储区的和分布式多机,以及混合式的),指令级并行。

ILP(指令级并行),x86架构里叫SMT(同时多线程),在MIPS架构里与之对应的是super scalar(超标量)和乱序执行,二者有区别,但共同点都是可以达到指令级并行,这是用户没法控制的,不属于编程范围,只能做些有限的优化,而这有限的优化可能只属于编译器管辖的范畴,用户能做的甚少。

(3)典型的适于并行计算的语言

ErlangMPI:这两个前者是语言,后者是C++和Fortran的扩展库,效果是一样的,利用多进程实现并行计算,Erlang是共享存储区的,MPI是混合型的。

C#.NET4.0:新版本4.0可以用少量代码实现并行For循环,之前版本需要用很繁琐的代码才能实现同样功能。这是利用了多线程实现并行计算。Java和C#3.5都有线程池(ThreadPool),也是不错的很好用的多线程管理类,可以方便高效的使用多线程。

CUDA,还是个初生牛犊,有很大的发展潜力,只不过就目前其应用领域很有限。其目前只能使用C语言,而且还不是C99,比较低级,不能使用函数指针。个人感觉这由于硬件上天生的局限性(平均每个核心可用内存小,与系统内存通讯时间长),只适用于做科学计算,静态图像处理,视频编码解码,其他领域,还不如高端CPU。等以后GPU有操作系统了,能充分调度GPU资源了,GPU就可以当大神了。游戏中的物理加速,实际上多核CPU也能很好的做到。

其他语言。。。恩。。留作将来讨论。

 

3.线程越多越好吗?什么时候才有必要用多线程?

线程必然不是越多越好,线程切换也是要开销的,当你增加一个线程的时候,增加的额外开销要小于该线程能够消除的阻塞时间,这才叫物有所值。

Linux自从2.6内核开始,就会把不同的线程交给不同的核心去处理。Windows也从NT.4.0开始支持这一特性。

什么时候该使用多线程呢?这要分四种情况讨论:

a.多核CPU——计算密集型任务。此时要尽量使用多线程,可以提高任务执行效率,例如加密解密,数据压缩解压缩(视频、音频、普通数据),否则只能使一个核心满载,而其他核心闲置。

b.单核CPU——计算密集型任务。此时的任务已经把CPU资源100%消耗了,就没必要也不可能使用多线程来提高计算效率了;相反,如果要做人机交互,最好还是要用多线程,避免用户没法对计算机进行操作。

c.单核CPU——IO密集型任务,使用多线程还是为了人机交互方便,

d.多核CPU——IO密集型任务,这就更不用说了,跟单核时候原因一样。

 

4.程序员需要掌握的技巧/技术

(1)减少串行化的代码用以提高效率。这是废话。

(2)单一的共享数据分布化:把一个数据复制很多份,让不同线程可以同时访问。

(3)负载均衡,分为静态的和动态的两种。具体的参见有关文献