• 深入理解计算机系统12——并发编程


    如果逻辑控制流在时间上重叠,那么它们就是并发的

    这种常见的现象称为并发。其实并发出现在计算机的很多层面上:硬件异常处理,进程和Linux信号处理程序都是;

    但是这里主要将并发看作是一种操作系统内核用来运行多个应用程序的机制

    但是并发并不局限于内核,它可以再应用程序中扮演重要的角色。

    例如:看到Unix信号处理程序如何允许应用响应异步事件,例如用户键入ctrl-c,或者程序访问虚拟存储器的一个未定义的区域。应用级并发有时候也很有用。

    1)访问慢速I/O设备

      当一个应用程序正在等待来自慢速I/O设备的数据到达时,内核会运行其他进程,使CPU保持繁忙。

      每个应用都可以按照类似的方式,通过交替执行I/O请求和其他有用的工作来使用并发。

    2)与人交互

      和计算机交互的人要求计算机有同时执行多个任务的能力。

      现代视窗系统利用并发来提供这种能力。每次用户请求某种操作时,一个独立的并发逻辑流被创建来执行这个操作。

    3)通过推迟工作以降低延迟

      有时,应用程序能够通过推迟其他操作和并发地执行它们,利用并发来降低某些操作的延迟。

      比如,一个动态存储分配器可以通过延迟合并,把它放到一个运行在较低优先级上的并发“合并”流中,在有空闲CPU周期时充分利用这些空闲周期,从而降低单个free操作的延迟。

    4)服务多个网络客户端

      创建一个并发服务器以替代迭代服务器,来使得服务器能够同时为成千上万个客户端提供服务。

    5)在多核机器上进行并行计算

      多核处理器中包含多个CPU。被划分成并发流的应用程序通常在多核机器上比在单处理器机器上运行得快,因为这些流会被并行地执行,而不是交错执行

    使用应用级并发的应用程序称为并发程序

    现代操作系统提供了三种基本的构造并发程序的方法:

    进程:每个逻辑控制流都是一个进程,由内核来调度和维护。因为进程有独立的虚拟地址空间,想要和其他流通信,控制流必须使用某种显式的进程间(IPC interprocess communication)通信机制

    I/O多路复用:在这种形式的并发编程中,应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。逻辑流被模型化为状态机,数据到达文件描述符后,主程序显式地从一个状态转换到另一个状态。因为程序是一个单独的进程,所以所有的流都共享同一个地址空间。

    线程:线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。可以把线程看作是其他两种方式的混合体。像进程流一样由内核进行调度,而像I/O多路复用流一样共享一个虚拟地址空间。

    ====================================================

    1、基于进程的并发编程

     构造并发程序最简单的方法就是用进程。

    基于进程的并发服务器

    基本方式就是父进程中接受客户端的连接请求,然后创建一个新的子进程来为每个新客户端提供服务。

    关于进程的优劣

    对于在父子进程间共享状态信息,进程有个非常清晰的模型:共享文件表,但是不能共享用户地址空间。

    进程有独立的地址空间既是优点也是缺点:

    优点是进程不可能因为一不小心而覆盖另一个进程的虚拟存储器,这消除了很多令人迷惑的错误。

    缺点是使得共享状态信息变得困难。为了共享信息,它们必须使用显式的IPC(进程间通信)机制。

    基于进程的设计的另一个缺点是,它们往往比较慢,因为进程控制和IPC的开销很高。

    ====================================================

    2、基于I/O多路复用的并发编程

    服务器必须响应两个互相独立的I/O事件。

    1)网络客户端发起连接请求

    2)用户在键盘上键入命令行

    我们要优先等待哪个事件呢?没有哪个选择是理想的。

    针对这种困境的解决方法是I/O多路复用技术。

    基本思路就是使用select函数,要求内核挂起进程,只有在一个活多个I/O事件发生后,才将控制返回给应用程序。

    基于I/O多路复用的并发事件驱动服务器

    I/O多路复用可以用作并发事件驱动程序的基础。

    在事件驱动程序中,是因为某种事件而前进的。

    一般概念是将逻辑流模型化为状态机

    不严格地说,一个状态机就是一组状态、输入事件、转移

    转移就是将状态和输入事件映射到状态。

    每个转移都将一个对(输入状态,输入事件)映射到一个输出状态。

    自循环是同一输入和输出状态间的转移。

    通常把状态机画成有向图,其中节点表示状态,有向弧表示转移,而弧上的标号表示输入事件。

    一个状态机从某种初始状态开始执行。

    每个输入事件都会引发一个从当前状态到下一个状态的转移。

    I/O多路复用技术的优劣

    事件驱动程序设计的一个优点是:它比基于进程的设计给了程序员更多的对程序行为的控制。

    另一个优点:基于I/O多路复用的事件驱动服务器是运行在单一进程上下文中的。因此每个逻辑流都能访问该进程的全部地址空间,这使得流之间共享数据变得很容易。

    事件驱动设计的一个明显缺点是编码复杂。随着并发粒度减小,复杂性还会上升。

    这里的粒度是指每个逻辑流每个时间片执行的指令数量。

    还有一个缺点是不能充分利用多核处理器

    ====================================================

    3、基于线程的并发编程

    上面介绍了两种创建并发逻辑流的方法。

    接下来介绍第三种方法:基于线程。它是两种方法的混合。

    线程就是运行在进程上下文中的逻辑流。

    现代操作系统允许我们编写一个进程里同时运行多个线程的程序。

    线程由内核调度。

    每个线程都有自己的线程上下文

    这个上下文包括:整数线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码

    所有运行在一个进程里的线程共享该进程的整个虚拟地址空间

    线程的执行模型

    每个进程开始生命周期时都是单一线程,这个线程成为主线程

    在某个时刻,主线程创建一个对等线程(peer thread)

    从这个时刻开始,两个线程并发地运行。

    最后,因为主线程执行一个慢速系统调用,或者它被系统的间隔计时器中断,控制就会通过上下文切换传递到对等线程。

    对等线程会执行一段时间,然后控制传递回主线程。以此类推。

    线程与进程的不同之处

    1)线程的上下文切换比进程的上下文切换要快得多,因为线程的上下文要比进程的上下文小得多;

    2)线程不像进程那样,不是严格按照父子层次来组织的。和一个进程相关的线程组成一个对等线程池(pool),独立于其他进程创建的线程。

    3)主线程和其他线程的区别仅在于它总是进程中第一个运行的线程。

    4)对等线程池概念的主要影响是,一个线程可以杀死它的任何对等线程,或者等待它的任意对等线程终止。

    5)另外,每个对等线程都能读写相同的共享数据。

    Posix线程

    Posix线程是在C程序中处理线程的一个标准接口。

    Pthreads 定义大约60个函数,允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可以通知对等线程系统状态的变化。

    创建线程:pthread_create

    终止线程:pthread_exit

    回收已终止线程的资源:pthread_join  等待其他线程终止。

    分离线程:pthread_detach  可结合的线程是能够被其他线程回收其资源并杀死的,分离的线程是不能被其他线程回收或杀死的。

    初始化线程:pthread_once 

    ====================================================

    4、多线程程序中的共享变量

    线程很有吸引力的一个方面就是多个线程很容易共享相同的程序变量。

    但是这种共享也是棘手的。我们必须对所谓的共享以及它是如何工作的有很清楚的了解。

    为了理解C程序中的一个变量是否是共享的,有一些基本的问题要解答:

    1)线程的基础存储器模型是什么?

    2)根据这个模型,变量实例是如何映射到存储器的?

    3)最后,有多少线程引用这些实例?一个变量是共享的,当且仅当多个线程引用这个变量的某个实例。

    线程存储器模型

    一组并发线程运行在一个进程的上下文中。

    每个线程都有它自己独立的线程上下文,包括线程ID、栈、栈指针、程序计数器、条件码、通用目的寄存器值。

    每个线程和其他线程一起共享进程上下文的剩余部分。

    这个剩余部分包括整个用户虚拟地址空间,它是由只读文本(代码)、读/写数据、堆以及所有的共享库代码和数据区域组成的。

    线程也共享同样的打开文件的集合。

    寄存器是从不共享的,而虚拟存储器总是共享的。

    将变量映射到存储器

    线程化的C程序中变量根据它们的存储类型被映射到虚拟存储器:

    全局变量

      全局变量是定义在函数之外的变量。

      在运行时,虚拟存储器的读/写区域只包含每个全局变量的一个实例,任何线程都可以引用。

      

    本地自助变量

      本地自动变量就是定义在函数内部但是没有static属性的变量。

      在运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。

    本地静态变量

       定义在函数内部并有static属性的变量。

      和全局变量一样,虚拟存储器的读/写去也只包含在程序中声明的每个本地静态变量的一个实例。

    共享变量

       我们说一个变量是共享的,是指当且仅当它的一个实例被一个以上的线程引用时。

    ====================================================

    5、用信号量同步线程

    共享变量的使用是十分方便的,但是它们也引入了同步错误的可能性。

    进度图将n个并发线程的执行模型化为一条n维笛卡尔空间中的轨迹线。

    我们希望确保每个线程在执行它的临界区中的指令时,拥有对共享变量的互斥访问。通常这种现象称为互斥

    两个临界区的交集形成的状态空间称为不安全区

    有些轨迹如果穿越了不安全区,那么这种行为就是不安全的。

    信号量

    信号量是一种特殊类型的变量,用于解决同步不同执行线程问题。

    信号量s是具有非负整数值的全局变量,只能由两种特殊的操作来处理,这两种操作称为P和V:

    P,如果s是非零,则P将s减1;

    V,将s加1

    P和V确保了一个正在运行的程序绝不可能进入这样一种状态,也就是一个正确初始化的信号量有一个负值。

    这个属性称为信号不变性,为控制并发程序的轨迹线提供了强有力的工具。

    Posix标准定义了许多操作信号量的函数。

    使用信号量来实现互斥

    信号量是一种特殊的共享变量。

    它提供了一种很方便的方法来确保对共享变量的互斥访问

    基本思想是将每个共享变量与一个信号量联系起来。

    然后用P和V操作将相应的临界区包围起来

    以这种方式来保护共享变量的信号量叫做二元信号量,因为它的值总是0或1。

    以提供互斥为目的的二元信号量常常也称为互斥锁

    在一个互斥锁上执行P操作称为对互斥锁加锁。类似地,执行V操作称为对互斥锁解锁

    对互斥锁加锁,但是没有对互斥锁解锁的线程称为占用这个互斥锁。

    一个被用作一组可用资源的计数器的信号量称为计数信号量

    P和V操作创建的禁止区使得在任何时间点上,在被包围的临界区中,不可能有多个线程在执行指令。

    换句话说,信号量操作确保了对临界区的互斥访问。就像不可能同时有两个人在上一个茅坑一样。

    最终的目的,无论是在单处理器还是多处理器上运行程序,都要同步你对共享变量的访问

    利用信号量来调度共享资源

    除了提供互斥之外,信号量还有一个重要的作用是,调度对共享资源的访问

    在这种场景中,一个线程用信号量来通知另一个线程,程序状态中的某个条件已经成真了。

    以下有两个经典而有用的例子:

    1、生产者-消费者问题

     

    生产者和消费者线程共享一个有n个槽的有限缓冲区。

    生产者线程反复地生成新的项目(item),并把它们插入到缓冲区中。

    消费者线程不断地从缓冲区中取出这些项目,然后消费它们。

    因为插入和取出项目都涉及更新共享变量,所以我们必须保证对缓冲区的访问的都是互斥的

    但是仅仅保证互斥还是不够的,我们还需要调度对缓冲区的访问

    如果缓冲区是满的(没有空的槽位),那么生产者必须等待到有空的槽位可用为止。

    如果缓冲区是空的(没有可取用的槽位),那么消费者必须等待直到有一个项目变为可用为止。

    2、读者-写者问题

    读者-写者问题是互斥问题的一个概括。

    一组并发地线程要访问一个共享对象。有些线程只读对象,有些线程只修改对象。

    修改对象的线程叫做写者。

    只读对象的线程叫做读者。

    写者必须拥有对对象的独占的访问,而读者可以和无限多个其他的读者共享对象。

    读者-写者问题有几个变种: 

    1)读者优先,要求不要让读者等待,除非已经把使用对象的权限赋予了一个写者。换句话说,读者不会因为有一个写者在等待而等待。

    2)写者优先,要求一旦一个写者准备好可以写,它就会尽可能地完成它的写操作。在一个写者后到达的读者必须等待。

    第一个变种会引发一个问题就是饥饿。如果有读者不断地到达,写者就可能无限期地等待。

    ====================================================

    6、使用线程提高并行性

    对于并行性的利用越来越重要。

    许多现代处理器都有多核,并发程序通常在这样的机器上运行得更快。

    Web服务器、浏览器,数据库服务器等应用中,并行性也变得越来越有用。

    所有程序的集合都可以划分成不相交的顺序程序的集合并发程序的集合

    写顺序程序只有一条逻辑流。

    写并发程序有多条并发流。

    并行程序是一个运行在多个处理器上的并发程序。

    因此,并行程序的集合是并发程序集合的真子集。

    并行程序有一些衡量程序性能的指标。称为加速比效率

    效率是对并行化造成的开销的衡量。高效率的程序在同步和通信上花费的时间更短。

    强扩展 弱扩展;

    ====================================================

    7、其他并发问题

    线程安全

    定义出四类线程不安全的函数(不相交):

    1)不保护共享变量的函数

    2)保持跨越多个调用的状态的函数

    伪随机数是这类线程不安全函数的简单例子;

    3)返回指向静态变量的指针的函数

    4) 调用线程不安全函数的函数

    可重入性

    可重入函数是线程安全函数的真子集。

    可重入函数的特点是不会引用任何共享数据。 

    在线程化的程序中使用已存在的库函数

    Unix系统提供大多数线程不安全函数的可重入版本。

    可重入版本的名字总是以“_r”后缀结尾的。

    竞争 

    死锁

    指的是一组进程被阻塞了,它们在等待一个永远不会为真的条件。

    解决方法是:添加互斥锁加锁顺序。按照这个顺序来求锁,并按照这个顺序来解锁。

    ====================================================

    8、小结

    一个并发程序是由在时间上重叠的一组逻辑流组成的。

    三种不同的构建并发程序的机制:进程、I/O多路复用和线程

     

    进程是由内核自动调度的,而且因为它们有各自独立的虚拟地址空间,所以要实现共享数据,必须要有显式的IPC机制。

    事件驱动程序创建它们自己的并发逻辑流,这些逻辑流被模型化为状态机,用I/O多路复用来显式地调度这些流。因为程序运行在单一的进程中,所以在流之间共享数据速度很快而且很容易。

    线程是这两种方法的综合。

    线程也是由内核自动调度的。但是线程是运行在一个单一进程的上下文中,因此可以快速而方便地共享数据。

    无论哪种并发机制,同步对共享数据的并发访问都是一个困难的问题。

    信号量P和V操作可以用来提供对共享数据的互斥访问;

    生产者-消费者程序中的有限缓冲区或者读者-写者系统中的共享对象这样的资源访问进行调度。

    并发也引入一些困难的问题。

    被线程调用的函数必须具有一个称为线程安全的属性。

    我们定义了四类线程不安全的函数,以及一些将它们变为线程安全的建议。

    可重入函数是线程安全的真子集,它不访问任何共享数据。

    可重入函数通常比不可重入函数更加有效,因为它不调用任何同步原语。

    竞争死锁是并发程序中出现的另一些困难的问题。

    当程序员错误地假设逻辑流该如何调度时,就会发生竞争。

    当一个流等待永远不会发生的事件时,就会发生死锁。

  • 相关阅读:
    mysql 性能优化方案
    MYSQL 优化常用方法
    MongoDB集群架构及搭建
    memcache分布式 [一致性hash算法] 的php实现
    memcache 的内存管理介绍和 php实现memcache一致性哈希分布式算法
    【转】linux 查看进程启动路径
    centos7 编译安装nginx+tcp+grpc转发
    mongodb笔记
    【转】mysql 解事务锁
    【转】centos7 搭建etcd集群
  • 原文地址:https://www.cnblogs.com/grooovvve/p/10596122.html
Copyright © 2020-2023  润新知