【计算机基础】了解并发编程

本文深入探讨了并发编程的概念,包括并发与并行的区别,进程与线程的工作原理,I/O多路复用技术以及其优劣。通过代码示例解释了基于进程和线程的并发编程,讨论了并发问题如死锁和竞争条件,并介绍了信号量作为同步工具。内容涵盖了从基础的进程上下文到高级的线程同步技术,为理解和实现并发程序提供了全面的视角。
摘要由CSDN通过智能技术生成


1)并发与并发编程

1.1 什么是并发(concurrency)?

进程是一个执行中程序的实例,进程提供给应用程序的关键抽象有:

  • 一个独立的逻辑控制流:提供一个程序是独占地使用处理器的假象。
  • 一个私有的地址空间:提供一个程序是独占地使用内存系统的假象。


如果用调试器单步执行程序,则会看到一系列的程序计数器(PC)的值,这个PC值的序列就是逻辑控制流(简称逻辑流)

  • PC值唯一地对应于指令(指令是在程序的可执行目标文件中,或者在运行时动态链接到程序的共享对象中)。
    在这里插入图片描述
    图中是一个运行着三个进程的系统,处理器的一个物理控制流被分成三个逻辑流,每个竖直的条表示一个进程的逻辑流的一部分
    • 关键点在于进程是轮流使用处理器的,每个进程执行其流的一部分,然后被抢占(preempted)(暂时挂起),然后轮到其他进程。
    • 计算机系统中的逻辑流有许多不同的形式,如异常处理程序、进程、信号处理程序、线程、Java进程。
  • 并发流(concurrent flow):一个逻辑流的执行在时间上与另一个流重叠。
    • 例如上图中,A和B是并发地运行,A和C也是,但是B和C没有。
  • 并发(concurrency):多个流并发地执行的一般现象。
  • 多任务(multitasking):一个进程和其他进程轮流运行。
    • 时间片(time slice):一个进程执行它的控制流的一部分的每一时间段。
    • 所以多任务也叫时间分片(time slicing)。
    • 例如上图中进程A的流由两个时间片组成。

*并发 VS 并行

  • 并行流(parallel flow):两个流并发地运行在不同的处理器核或者计算机上。

    • 它们并行地运行(running in parallel),而且并行地执行(parallel execution)。
  • 而并发流的思想与流运行的处理器核数或者计算机数无关,只要两个流在时间上是重叠的,那么它们就是并发的。

    • 因此并行流是并发流的一个真子集

1.2 什么是并发编程?

使用应用级并发的应用程序被称为并发程序(concurrent program)。

  • 并发不仅仅局限于内核,应用级并发在很多情况下也是很有用的:
    • 访问慢速I/O设备:通过交替执行I/O请求和其他有用的工作来利用并发。
    • 与人交互:每次用户请求某种操作,一个独立的并发逻辑流被创建来执行该操作。
    • 通过推迟工作以降低延迟:推迟其他操作,并发地执行它们。
    • 服务多个网络客户端:一个并发服务器为每个客户端创建一个单独的逻辑流。
    • 在多核机器上进行并行运算

有三种基本的构造并发程序的方法(即并发编程技术):

  • 进程:每个逻辑控制流都是一个进程,由内核来调度和维护。
  • I/O多路复用:应用程序在一个进程的上下文中显式地调度它们自己的逻辑流。
  • 线程:线程是运行在一个单一进程上下文中的逻辑流,由内核进行调度。

内核为每个进程维持一个上下文,内核使用一种上下文切换的异常控制流来实现多任务。

  • 上下文(context):是内核重新启动一个被抢占的进程所需的状态,由一些对象的值组成(对象包括寄存器、浮点寄存器、程序计数器、用户栈、状态寄存器、内核栈、各种内核数据结构)。


2)基于进程的并发编程

构造并发服务器的常用方法就是,在父进程中接受客户端连接请求,然后创建一个新的子进程来为每个新客户端提供服务。

  • 工作原理:

    1. 服务器接收客户端的连接请求:服务器正在监听一个监听描述符(比如指述符3)上的连接请求。
      在这里插入图片描述

    2. 服务器派生一个子进程来为这个客户端服务:服务器返回一个已连接描述符(比如指述符4),派生的子进程获得服务器描述符表的完整副本。子进程关闭其副本中的监听描述符3,而父进程关闭其已连接描述符4的副本非常重要,否则将永不会释放已连接描述符4的文件表条目,由此会引发内存泄漏)。
      在这里插入图片描述

    3. 服务器接收另一个连接请求
      在这里插入图片描述

    4. 服务器派生另一个子进程为新的客户端服务:同样的,父进程返回一个新的已连接描述符(如描述符5)。
      在这里插入图片描述


  • 代码举例

    一个基于进程的并发echo服务器的代码如下:
    1 #include "csapp.h"
    2 void echo(int connfd);
    3
    4 void sigchld_handler(int sig)  //回收一个或多个僵死(zombie)子进程的资源
    5 {
    6 		while (waitpid(-1, 0, WNOHANG) > 0)
    7 			;
    8	 	return;
    9 }
    10
    11 int main(int argc, char **argv)
    12 {
    13 		int listenfd, connfd;
    14 		socklen_t clientlen;
    15		struct sockaddr_storage clientaddr;
    16
    17		if (argc != 2) {
    18 		fprintf(stderr, "usage: %s <port>\n", argv[0]);
    19 		exit(0);
    20 }
    21
    22		Signal(SIGCHLD, sigchld_handler);
    23 		listenfd = Open_listenfd(argv[1]);
    24 		while (1) {
    25			clientlen = sizeof(struct sockaddr_storage);
    26 		 	connfd = Accept(listenfd, (SA *) &clientaddr, &clientlen);
    27 			if (Fork() == 0) {
    28			 	Close(listenfd); /* Child closes its listening socket */
    29			 	echo(connfd); /* Child services client */
    30 			 	Close(connfd); /* Child closes connection with client */
    31 		 	 	exit(0); /* Child exits */
    32 		 	}
    33  	 	Close(connfd); /* Parent closes connected socket (important!) */
    34 		 }
    35 }
    
    • 父子进程必须关闭各自的connfd副本(对应第30和33行)。
      • 特别注意第33行,父进程必须关闭它的已连接描述符,以避免内存泄漏。
    • 最后,因为套接字的文件表表项中的引用计数,直到父子进程的connfd都关闭了,到客户端的连接才会终止。

  • 进程的优劣:

    • (优点)一个进程不会覆盖另一个进程的虚拟内存:父子进程间共享状态信息利用的是共享文件表,但是不共享用户地址空间。
    • (缺点)进程间共享状态信息较慢:因为独立的地址空间,使得进程间只能使用显式的IPC(进程间通信)机制来共享信息,而进程控制和IPC的开销较高。


3)基于I/O多路复用的并发编程

假如一个服务器需要响应多个互相独立的I/O事件,比如echo服务器也能对用户从标准输入键入的交互命令作出响应,则服务器需要响应:

  • 网络客户端发起连接请求
  • 用户在键盘上键入命令行

如果在accept中等待一个连接请求,那么就无法响应输入的命令,反之,如果在read中等待一个输入命令,那么就无法响应任何连接请求。

  • 对于这种问题,一个解决办法就是I/O多路复用(I/O multiplexing)技术

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

  • I/O多路复用技术可以用作并发事件驱动(event-driven)程序的基础。
    • 一般思路是将逻辑流模型转化为状态机(state machine)。
      • 状态机就是一组状态(state)、输入事件(input event)和转移(transaction)。

  • 工作原理:

    基本原理就是 select/epoll 这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。流程如下:
    在这里插入图片描述
    • 这里需要使用两个系统调用(select和recvfrom)

      • 当用户进程调用了select,那么整个进程会被block,与此同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。
      • 这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。
    • 在多路复用模型中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

    • select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接


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

    • 优点:
      • 给了程序员更多的对程序行为的控制
      • 高效:因为是运行在单一进程上下文中的,所以每个逻辑流都能访问该进程的全部地址空间,使得流之间共享数据更容易,而且也不需要进程上下文切换来调度新的流。
    • 缺点:
      • 编码复杂:随着并发粒度的减小,复杂性会上升。
        • 并发粒度是指每个逻辑流每个时机片执行的指令数量。
      • 不能充分利用多核处理器

虽然有如上缺点,但因为相比进程和线程的方式,它有明显的性能优势,因此现代高性能服务器(如Node.js、nginx、Tornado)使用的都是基于I/O多路复用的事件驱动的编程方式。



4)基于线程的并发编程

线程是前两种方法的混合。

线程(Thread)是运行在进程上下文的逻辑流,由内核自动调度。

  • 每个线程都有自己的线程上下文(thread context),包括一个唯一的整数线程ID(Thread ID,TID)、栈、栈指针、程序计数器、通用目的寄存器和条件码。
  • 所有运行在一个进程中的线程共享该进程的整个虚拟地址空间。
  • 线程由内核自动调度,并且内核通过TID来识别线程。

4.1 工作原理

  • 线程执行模型:

    每个进程开始生命周期时都是单一线程(主线程,main thread):
    在这里插入图片描述
    • 在某一时刻,主线程创建一个对等线程(peer thread),从该时间点开始,这两个线程就并发地运行。
    • 因为主线程执行一个慢速系统调用(如read或者sleep),或者因为被系统的间隔计数器中断,控制就会通过上下文切换传递到对等线程或者主线程。

在一些重要的方面,线程执行是不同于进程的:

  • 线程的上下文切换比进程的快得多:因为线程的上下文比进程的小很多。
  • 线程不是按照严格的父子层次来组织的:与一个进程相关的线程组成一个对等(线程)池,独立于其他线程创建的线程。
    • 一个线程可以杀死它的任何对等线程,或者等待其任意对等线程终止。
    • 每个对等线程能读写相同的共享数据。
  • Posix线程(Pthreads):

    Pthreads是C程序中处理线程的一个标准接口,在所有的Linux系统上都可用。
    • 允许程序创建、杀死和回收线程,与对等线程安全地共享数据,还可用通知对等线程系统状态的变化。
    • 创建线程:调用pthread_create函数
    • 终止线程
      • 当顶层的线程例程返回时,线程会隐式地终止。
      • 通过调用pthread_exit函数,线程会显式地终止。
        • 如果主线程调用该函数,它会等待所有其他对等线程终止,然后终止主线程和整个进程,返回值为thread_return。
    • 回收已终止线程的资源:通过调用thread_join函数等待其他线程终止,将线程例程返回的通用(void*)指针赋值为thread_return指向的位置,然后回收已终止的线程占用的所有内存资源。
      • 只能等待一个指定的线程终止,无法等待任意一个线程终止。
    • 分离线程:pthread_detach函数分离可结合线程tid,线程能够通过以pthread_self()为参数的pthread_detach调用来分离它们自己。
      • 在任何一个时间点上,线程是可结合的(joinable)或者分离的(detached),默认情况下线程被创建为可结合的,为了避免内存泄漏,每个可结合线程都应该要被其他线程显式地收回,或者通过调用pthread_detach函数被分离
      • 一个分离的线程是不能被其他线程回收或杀死的,其内存资源在它终止时由系统自动释放。
    • 初始化线程:pthread_once函数允许初始化与线程例程相关的状态。

4.2 多线程程序中的共享变量

为了理解C程序中的一个变量是否是共享的,需要知道以下几个问题:

  • 线程的基础内存模型是什么?
  • 根据该模型,变量实例是如何映射到内存的?
  • 有多少线程引用这些实例?
    • 当且仅当多个线程引用这个变量的某个实例时,这个变量是共享的。
  • 线程内存模型:

    一组并发线程运行在一个进程的上下文中:
    • 每个线程都有自己独立的线程上下文,包括:线程ID、栈、栈指针、程序计数器、条件码和通用目的寄存器值。
    • 每个线程和其他线程一起共享进程上下文的剩余部分,这包括整个用户虚拟地址空间(由只读文本即代码、读/写数据、堆以及所有的共享代码和数据区域组成)。
    • 线程也共享相同的打开文件的集合。

  • 变量如何映射到内存:

    多线程的C程序中变量根据它们的存储类型被映射到虚拟内存:
    • 全局变量:定义在函数之外的变量。
      • 运行时,虚拟内存的读/写区域只包含每个全局变量的一个实例,任何线程都可用引用。
    • 本地自动变量:定义在函数内部但是没有static属性的变量。
      • 运行时,每个线程的栈都包含它自己的所有本地自动变量的实例。
    • 本地静态变量:定义在函数内部且有static属性的变量。
      • 和全局变量一样,虚拟内存的读/写区域只包含在程序中声明的每个本地静态变量的一个实例。

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


4.3 用信号量同步线程

共享变量很方便,但是也引入了同步错误(synchronization error)的可能性。

  • 例如两个对等线程在一个单处理器上并发运行时,机器指令以某种顺序一个接一个完成。
    • 每个并发执行定义了两个线程中的指令的某种全序(或者交叉),但是这些顺序中有一些不会产生正确结果。
    • 而通常而言,是没有办法预测操作系统是否为线程选择了一个正确的顺序。

一种基于叫信号量(semaphore)的特殊类型变量的方法是经典的解决同步不同执行线程问题的方法。

  • 什么是信号量?

    信号量s是具有非负整数值的全局变量,只能由两种特殊的操作来处理(P和V):
    • P(s):如果s非零,则s减1,且立即返回;如果s为零,则挂起线程直到s非零。
    • V(s):将s加1。如果有任何线程阻塞在P操作等待s变为非零,那么V操作会重启这些线程中的一个,然后该线程将s减1,完成它的P操作。
    • P和V的定义确保了不会存在一个信号量有一个负值,这个属性称为信号量不变性(semaphore invariant)。

  • 使用信号量实现互斥:

    信号量提供了一种很方便的方法来确保对共享变量的互斥访问,基本思想是将每个共享变量(或一组相关的共享变量)与一个信号量s(初始为1)联系起来,然后用P和V操作将相应的临界区包围起来。
    • 互斥锁(mutex):通过这种方式来保护共享变量的信号量叫二元信号量(binary semaphore),因为其值总是1或0,也常被称为互斥锁。
    • 对互斥锁加锁:在一个互斥锁上执行P操作
    • 对互斥锁解锁:在一个互斥锁上执行V操作
    • 占用这个互斥锁:一个互斥锁加了锁但是还没有解锁的线程
    • 计数信号量:一个被用作一组可用资源的计数器的信号量
    • P和V操作的结合创建了一组状态,叫禁止区(forbidden region),其中s<0:
      • 禁止区包含不安全区,又由于信号量的不变性,没有实际可行的轨迹线能够接触不安全区,所以每条实际可行的轨迹线都是安全的,而且无论运行时指令顺序是什么样的,程序都会正确地增加计数器值。

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

    一个线程用信号量操作来告知另一个线程,程序状态中的某个条件已经为真了。
    • 经典的例子是生产者-消费者读者-写者问题。

除了信号量,还有其他的线程同步技术,比如Java线程是用一种叫Java监控器(Java Monitor)的机制来同步的。


4.4 常见的并发问题

虽然是以线程为例进行讨论的,但这些典型的问题是任何类型的并发流操作共享资源时都会出现的。

  • 线程不安全函数类:

    线程安全的(thread-safe)函数:当且仅当被多个并发线程反复地调用时,它会一直产生正确的结果。如果一个函数不是线程安全的,则就叫它为线程不安全的(thread-unsafe)。
    • 不保护共享变量的函数:对一个未受保护的全局计数器变量加1。
      • 可以通过P和V这样的同步操作来保护共享的变量,优点是调用程序中不需要做任何改变,缺点是同步操作会减慢程序的执行时间。
    • 保持跨越多个调用的状态的函数:当前调用的结果依赖于前次调用的中间结果。
      • 解决方法是重写。
    • 返回指向静态变量的指针的函数:一个线程使用的结果会被另一个线程悄悄地覆盖了。
      • 要么重写函数,要么使用加锁-复制(lock-and-copy)技术。
    • 调用线程不安全函数的函数

  • 竞争(race):

    当一个程序的正确性依赖于一个线程要在另一个线程到达y点之前到达它的控制流中的x点时,就会产生竞争。
    • 为了消除竞争,可以动态地为每个整数ID分配一个独立的块,并且传递给线程例程一个指向该块的指针,注意线程例程必须释放这些块以避免内存泄漏。

  • 死锁(deadlock):

    指的是一组线程被阻塞了,等待一个永远不会为真的条件。
    • 程序员使用P和V操作顺序不当,可能会导致两个信号量的禁止区域重叠,从而引发了一组称为死锁区域的状态。如果一个轨迹正好到达了死锁区域中状态,那么死锁就不可避免了。
    • 死锁是相当困难的问题,因为其总是不可预测的。
    • 避免死锁简单而有效的规则:互斥锁加锁顺序规则,即给定所有互斥操作的一个全序,如果每个线程都是以一种顺序获得互斥锁并以相反的顺序释放,那么这个程序就是无死锁的。

以进程的角度:

  • 死锁是指多个进程在运行过程中因争夺资源而造成的一种僵局,若无外力作用,它们都将无法再向前推进。
  • 产生死锁的原因
    • 竞争资源:竞争不可剥夺的资源,比如进程A在占用该资源的时候,进程B请求该资源则会被阻塞。
    • 竞争临时资源:临时资源包括硬件中断、信号、消息、缓冲区内的消息等,如果消息通信顺序进行不当,则会产生死锁。
    • 进程之间的推进顺序非法

  • 面试常问之死锁的四个(必要)条件:
    • 互斥条件:在一段时间内某资源仅为一进程所占用。
    • 请求和保持条件:当进程因请求资源而阻塞时,对已获得的资源保持不放。
    • 不剥夺条件:进程已获得的资源在未使用完之前,不能剥夺,只能在使用完时由自己释放。
    • 环路等待条件:在发生死锁时,必然存在一个进程–资源的环形链。

  • 解决死锁的基本方法
    • 资源一次性分配:一次性分配所有资源,这样就不会再有请求了:(破坏请求条件)
    • 只要有一个资源得不到分配,也不给这个进程分配其他的资源:(破坏请保持条件
    • 可剥夺资源:即当某进程获得了部分资源,但得不到其它资源,则释放已占有的资源(破坏不可剥夺条件)
    • 资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)


【部分内容参考自】

  • 《深入理解计算机系统》
  • 并发编程(IO多路复用):https://www.cnblogs.com/cainingning/p/9556642.html
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值