操作系统概念 第四章 多线程编程

第四章 多线程编程

4.1 概述

每个线程是CPU使用的一个基本单元;包括线程ID、PC、寄存器组和堆栈。线程与同一进程的其他线程共享代码段、数据段和其他操作系统资源,如打开文件和信号。

4.1.1 动机

创建多线程的动机总体上来说就是为了提高速度。以Web服务器为例,当有多个客户机向同一个Web服务器发起请求。在线程流行之前的做法是,服务器(单线程进程)收到请求时会创建另一个进程处理请求,但进程创建很耗时间和资源,如果Web服务器进程时多线程的,那么就可以创建一个线程处理请求。创建线程的代价比创建进程小得多。

4.1.2 优点

多线程编程的四大类优点:

  • 响应性:如果一个交互程序采用的多线程,那么即使部分阻塞或执行冗长操作,它仍可以继续执行。例如,当用户点击一个按钮以便运行一个耗时的操作时,单线程进程会阻塞至该操作完成后才能继续与用户交互,结果是反应迟钝;但多线程进程可在一个线程执行该操作的同时,应用程序仍可相应用户。
  • 资源共享:线程默认共享所属进程的内存和资源,代码和数据共享的优点是:允许一个应用程序在同一地址空间内有多个不同的活动线程。
  • 经济:相比创建进程,创建和切换线程带来的开销小得多。
  • 可伸缩性:线程可在多处理核上并行运行,单线程进程只能运行在一个CPU上。(这里应该是想对比多线程进程,一个进程的多个线程在有多个CPU时多个线程可同时运行)

4.2 多核编程

并行性和并发性的区别。
Amdahl定律。

4.2.1 编程挑战

一般而言,多核系统编程有五个方面的挑战:

  • 识别任务:分析应用程序,查找区域以便分配独立的、并发的任务。
  • 平衡:确保任务执行同等价值的工作,为一个贡献度低的任务分配一个单独的核来执行这个任务就不值得了
  • 数据分割:数据也应随着任务的分割而分割
  • 数据依赖:当一个多个任务访问的数据之间有依赖关系时,需保证任务的执行是同步
  • 测试与调试

4.2.2 并行类型

通常有两种类型的并行:数据并行(data parallelism)注重将数据分布于多个计算核上,在各个核执行相同的操作。任务并行(task parallelism)设计将任务(线程)分配到多个计算核。实践中很少严格遵循数据或任务并行,大多数情况下,应用程序混合使用这两个策略。

4.3 多线程模型

有两种不同的方法来提供线程支持:用户层的用户线程(user thread)或内核层的内核线程(kernel thread)。用户线程位于内核之上,它的管理无需内核支持;而内核线程由操作系统来支持和管理。用户线程核内核线程必然存在某种关系。下面是三种常用的建立这种关系的方法:多对一模型、一对一模型和多对多模型。

4.3.1 多对一模型

映射多个用户级线程到一个内核线程。线程管理由用户空间的线程库来完成。如果一个线程执行阻塞系统调用,那么整个进程将会阻塞。因为任意时间只有一个线程可以访问内核,所以多个线程不能并行运行在多处理核系统上。

4.3.2 一对一模型

映射每个用户线程到一个内核线程。此模型在一个线程执行阻塞系统调用时,能够允许另一个线程继续执行,比多对一模型并发性更好。但创建一个用户线程就要创建一个相应的内核线程,这个开销会影响程序的性能。所以这种模型的大多数实现限制了系统支持的线程数量。

4.3.2 多对多模型

多路复用多个用户级线程到同样数量或更少数量的内核线程。
多对一模型允许开发人员创建任意多数量的线程,但内核一次只能调度一个线程,没有增加并发性;一对一模型提供了更大的并发性,但是不应在应用程序内船舰太多的线程。多对多模型没有这两个缺点。多对多模型的一个变种,在原来的基础上,允许绑定某个用户线程到一个内核线程,这个变种,称为双层模型(tow-level model)。

4.4 线程库

线程库(thread library)为程序员提供创建和管理线程的API。实现线程库的主要方法有两种,一是在用户空间提供一个没有内核支持的库;二是实现由操作系统直接支持的内核级的一个库。二者的区别是线程库的代码和数据结构的位置前者位于用户空间,后者位于内核空间,调用库内的函数时,前者只是导致用户空间的一个本地函数调用,后者会导致对内核的系统调用。目前使用的三种主要线程库:POSIX Pthreads、Windows、Java。

接下来的部分将用上面的三种线程库做介绍,这里先说明一下多线程创建的两个常用策略:异步线程和同步线程。

  • 异步线程:父线程创建了子线程后,父线程就恢复自身的执行,这样两个线程会并发的执行,由于线程时独立的,所以线程之间很少有数据共享。
  • 同步线程:如果父线程创建一个及以上的子线程后,需等待所有的子线程终止,子线程终止后会与父线程连接,所有的子线程连接后,父线程才恢复执行。通常同步线程涉及线程之间的大量数据的共享。

4.4.1 Pthreads

Pthreads时POSIX标准定义的线程创建与同步的API。这是线程行为的规范(specification),而不是实现(implementation)
下面用代码段展示基本的Pthreads API:

    #include   //所有Pthreads程序都要包括头文件pthread.h
    ...
    pthread_t tid;              //the thread identifier
    pthread_attr_t attr;        //set of thread attributes
    pthread_attr_init(&attr);   //get the default attributes
    pthread_create(&tid,&attr,runner,argv[]);   //create the thread, runner is the function to run
    pthread_join(tid,NULL);                     //wait for the thread to exit
    ...

4.4.2-3 Windows 线程和 Java 线程

略。

4.5 隐式多线程

为了解决多线程编程的困难,更好的支持设计多线程程序,有一种方法时将多线程的创建和管理交给编译器和运行时库来完成,这种策略称为隐式线程(implicit threading)。

4.5.1 线程池

线程池的主要思想是:在进程开始时创建一定数量的线程,并加载到池中等待工作。当有需要时,会唤醒池内的线程(如果有可用线程),一旦线程完成了服务,它会返回池中等待工作。如果没有可以线程会一直等待直到有空线程为止。
线程池具有以下优点:

  • 用现有线程服务请求比等待创建一个线程更快。
  • 线程池限制了任何时候可用线程的数量,对不能支持大量并发线程的系统非常重要。
  • 将要执行任务从创建任务的机制中分离出来,允许我们采用不同策略运行任务。

4.5.2 OpenMP

OpenMP为一组编译指令和API,用于编写C、C++、Fortran等语言的程序。OpenMP识别并行区域(parallel region),即可并行运行的代码块。应用程序开发人员在并行区域插入编译指令,这些指令指示OpenMP运行时库来并行执行这些区域。有兴趣的可以查阅资料深入了解。

4.5.3 大中央调度(Grand Central Dispatch,GCD)

GCD为C和C++增加了(block)的扩展。每块是工作的一个独立单元,用花括号{}将代码括起来,然后前面加上字符,一个简单的例子如下:

^{printf("I am a block");}

通过将这些块放置在调度队列(dispatchqueue)上,GCD调度块以执行。当GCD从队列移除一块后,就将该块分配给线程池中的可用线程。GCD识别两种类型的调度队列:串行(serial)(串行队列上的块按先进先出的顺序删除,每个进程都有自己的串行队列)和并发(concurrent)(并发队列上的块可以按前面的方法先进先出的删除,也可同时删除多个块)。

4.6 多线程问题

4.6.1 系统调用 fork() 和 exec()

对于多线程程序,fork()系统调用会根据系统和实际情况可以复制所有线程,也可以仅复制调用了fork()的线程。
exec()的工作方式与第三章所属方式通常相同。exec()参数指定的程序将会取代整个进程,包括所有线程

4.6.2 信号处理

UNIX信号(signal)用于通知进程某个特定事件已经发生。信号的接收可以是同步或异步的,取决于信号的来源和原因。无论如何都遵循相同的模式:

  • 由特定事件的发生而产生。
  • 信号被传递给某个进程。
  • 信号一旦收到就应处理。
    简单来说同步信号是进程内由于某种操作产生的信号发送到自己所在的进程;异步信号发送到另一个进程。

信号处理程序可以分为两种:

  • 缺省的信号处理程序。
  • 用户定义的信号处理程序。

单线程程序信号总是传递给进程,但对于多线程程序,信号应该被传递到哪里呢?:

  • 传递信号到信号所使用的线程。
  • 传递信号到进程内的每个线程。
  • 传递到进程内的某些线程。
  • 规定一个特定线程接收进程的所有信号。

信号传递的方法取决于产生信号的类型。传递信号的标准UNIX函数为:

kill(pid_t pid, int signal)

此函数将一个特定信号(signal)传递到一个进程(pid)。

4.6.3 线程撤销

线程撤销(thread cancellation)是在线程完成之前终止线程。例,如果多个线程同时执行搜索任务,其中一个线程搜索到指定目标,则可以撤销其他线程。需要撤销的线程通常称为目标线程(target thread)。撤销有两种情况:、

  • 异步撤销:一个线程立即终止目标线程。
  • 延迟撤销:目标线程不断检查它是否应终止,这允许目标线程有机会有序终止自己。
    对于Pthreads,通过函数 pthread_cancel(tid)可以发起线程撤销。然而调用此函数只表示有一个请求,实际撤销取决于如何设置目标线程以便处理请求。

4.6.4 线程本地存储

同一进程的线程共享进程的数据。某些情况下,每个线程可能需要它自己的某些数据,这种数据称为线程本地储存(Thread-Local Storage,TLS)。

4.6.5 调度程序激活

多线程编程需要考虑的最后一个问题设计内核与线程库间的通信,多对多和双层模型可能需要这种通信。这种协调允许动态调整内核线程数量,以便确保最优性能。许多系统实现多对多或双层模型是在用户和内核线程之间增加一个中间数据结构(轻量级进程(LightWeight Process,LWP)),以便应用程序调度并运行用户线程。
这里有关概念:调度器激活(scheduler activation)、回调(upcall)、回调处理程序(upcall handler)部分有兴趣自行查阅相关资料。
调度器激活工作如下:内核提供一组虚拟处理器(LWP,对用户级线程库LWP表现为虚拟处理器)给应用程序,应用程序可以调度用户线程到任意可以的LWP。当一个线程阻塞时,一个触发回调的事件会发生,内核会向应用程序发出一个回调,通知它有一个线程将会阻塞并标识出来。然后内核分配一个新的LWP给应用进程运行回调处理程序,它保存阻塞线程的状态,释放阻塞线程运行的LWP,接着调度一个适合在新的LWP上运行的线程。当阻塞线程等待的事件发生时,内核向线程库发送另一个回调,同样的,相应的回调处理程序会调度先前阻塞的线程。

4.7 操作系统例子

4.7.1 Windows 线程

略。

4.7.2 Linux 线程

除了fork(),系统调用clone()也提供创建线程的功能。这部分细节不再赘述,有兴趣请自行查阅相关资料。

你可能感兴趣的:(linux,ubuntu,学习,risc-v,unix)