【网络编程】同步和异步、阻塞和非阻塞,I/O和网络I/O
十、基于I/O模型的网络开发
10.1 同步和异步
对于多个线程而言,同步、异步就是线程间的步调是否要一致、是否要协调:要协调线程 之间的执行时机就是线程同步,否则就是异步。
对于一个线程的请求调用来讲,同步和异步的区别是是否要等这个请求出最终结果(注意, 不是请求的响应,是提交的请求最终得到的结果)。如果要等最终结果,就是同步;如果不等, 干其他无关事情了,就是异步。
10.1.1 同步
根据汉语大辞典,同步(Synchronization)是指两个或两个以上随时间变化的量在变化过 程中保持一定的相对关系,或者说,对在一个系统中所发生的事件(event) 之间进行协调, 在时间上出现一致性与统一化的现象。比如说,两个线程要同步,即它们的步调要一致,要相互协调来完成一个或几个事件。
同步也经常用在一个线程内先后两个函数的调用上,后面一个函数需要前面一个函数的结 果,那么前面一个函数就必须完成且有结果才能执行后面的函数。这两个函数之间的调用关系 就是一种同步(调用)。同步调用一旦开始,调用者就必须等到调用方法返回且结果出来(注 意一定要在返回的同时出结果,不出结果就返回那是异步调用)后才能继续后续的行为。同步 一词用在这里也是恰当的,相当于就是一个调用者对两件事情(比如两次方法调用)之间进行 协调(必须做完一件再做另外一件),在时间上保持一致性(先后关系)。
这么看来,计算机中的“同步”一词所使用的场合符合了汉典中的同步含义。
对于线程间而言,要想实现同步操作,就必须获得线程的对象锁。获得它可以保证在同一 时刻只有一个线程能够进入临界区,并且在这个锁被释放之前,其他的线程都不能再进入这个 临界区。如果其他线程想要获得这个对象的锁,只能进入等待队列等待。只有当拥有该对象锁 的线程退出临界区时锁才会被释放,等待队列中优先级最高的线程才能获得该锁。
同步调用相对简单些,比较某个耗时的大数运算函数及其后面的代码就可以组成一个同步调用,相应的,这个大数运算函数也可以称为同步函数,因为必须执行完这个函数才能执行后 面的代码。比如:
long long num = bigNum();
printf("%d",num);
可以说,bigNum 是同步函数,它返回时大数结果就出来了,然后执行后面的printf 函数。
10.1.2 异步
异步就是一个请求返回时一定不知道结果(如果返回时知道结果就是同步了),还得通过 其他机制来获知结果,如主动轮询或被动通知。同步和异步的区别就在于是否等待请求执行的 结果。这里请求可以指一个I/O 请求或一个函数调用等。
为了加深理解,我们举个生活中的例子。比如你去肯德基点餐,你说“来份薯条”,服务 员告诉你,“对不起,薯条要现做,需要等5分钟”,于是你站在收银台前面等了5分钟,拿 到薯条再去逛商场,这是同步。你对服务员说的“来份薯条”就是一个请求,薯条好了就是请 求的结果出来了。
再看异步,你说“来份薯条”,服务员告诉你,“薯条需要等5分钟,你可以先去逛商场, 不必在这里等,薯条做好了,你再来拿”。这样你可以立刻去干别的事情(比如逛商场),这 就是异步。“来份薯条”是一个请求,服务员告诉你的话就是请求返回了,但请求的真正结果 (拿到薯条)没有立即实现。异步一个重要的好处是不必在那里等,而同步肯定是要等的。
很明显,使用异步方式来编写程序性能和友好度会远远高于同步方式,但是异步方式的缺 点是编程模型复杂。
想想看,在上面的场景中,要想吃到薯条,你得知道“什么时候薯条好了”, 有两种方式:一种是你主动每隔一小段时间就跑到柜台上去看薯条有没有好(定时主动关注状 态),这种方式通常称为主动轮询;另一种是服务员通过电话、微信通知你,这种方式称为通 知(被动)。显然,第二种方式更高效。因此,异步还可以分为两种:带通知的异步和不带通 知的异步。
在上面的场景中,“你”可以比作一个线程。
10.2 阻塞和非阻塞
阻塞和非阻塞这两个概念与程序(线程)请求的事情出最终结果前(无所谓同步或者异步) 的状态有关。也就是说阻塞与非阻塞主要是从程序(线程)请求的事情出最终结果前的状态角度来说的。
10.2.1 阻塞
大家学操作系统课程的时候一定知道,线程从创建、运行到结束总是处于下面五个状态之一:新建状态、就绪状态、运行状态、阻塞状态及死亡状态。
阻塞状态的线程特点是:
该线程放弃CPU 的使用,暂停运行,只有等到导致阻塞的原因消除之后才恢复运行。或者是 被其他的线程中断,该线程也会退出阻塞状态,同时抛出InterruptedException。线程运行过程 中,可能由于各种原因进入阻塞状态:
- (1)线程通过调用sleep 方法进入睡眠状态。
- (2)线程调用一个在I/O 上被阻塞的操作,即该操作在输入输出操作完成之前不会返回 到它的调用者。
- (3)线程试图得到一个锁,而该锁正被其他线程持有。于是只能进入阻塞状态,等到获 取了同步锁,才能恢复执行
- (4)线程在等待某个触发条件。
- (5)线程执行了一个对象的wait()方法,直接进入阻塞状态,等待其他线程执行notify( 或者notifyAll()方法。
这里我们要关注一下第(2)条,很多网络I/O 操作都会引起线程阻塞,比如 recv 函数, 但数据还没有过来或还没有接收完毕,线程就只能阻塞等待这个I/O 操作完成。这些能引起线 程阻塞的函数通常称为阻塞函数。
阻塞函数其实就是一个同步调用,因为要等阻塞函数返回才能继续执行其后的代码。有阻 塞函数参与的同步调用一定会引起线程阻塞,但同步调用并不一定会阻塞,比如同步调用关系 中没有阻塞函数或引起其他阻塞的原因存在。举个例子,一个非常消耗CPU 时间的大数运算 函数及其后面的代码,这个执行过程也是一个同步调用,但会引起线程阻塞。
这里,我们可以区分一下阻塞函数和同步函数。同步函数被调用时不会立即返回,直到该函数所要做的事情全都做完了才返回。阻塞函数也是被调用时不会立即返回,直到该函数所要做的事情全都做完了才返回,而且会引起线程阻塞。这么看来,阻塞函数一定是同步函数,但同步函数不仅指阻塞函数。
强调一下,阻塞一定是引起线程进入阻塞状态的。
这里给出一个生活场景来加深理解:小明去买薯条,服务员告诉他5分钟后才能好,小明 说“好吧,我在这里等”,同时他睡了一会。这就是阻塞,而且是同步阻塞,在等并且睡着了。
10.2.2 非阻塞
非阻塞是指在不能立刻得到结果之前请求不会阻塞当前线程,而会立刻返回(比如返回一 个错误码)。虽然表面上看非阻塞的方式可以明显地提高CPU 的利用率,但是也带来另外一 种后果,就是系统的线程切换增加。增加的CPU 执行时间能不能补偿系统的切换成本需要好 好评估。
强调一下,非阻塞不会引起线程进入阻塞状态,而且请求是马上有响应的(比如返回一个 错误码)。
10.3 同步/异步和阻塞/非阻塞的关系
给一个生活场景来加深理解:你去买薯条,服务员告诉你5分钟后才能好,那你就站在柜 台旁开始等,但人没有睡过去,或许还在玩微信。这就是非阻塞,而且是同步非阻塞,在等但 没有睡过去,还可以玩玩手机。
如果你没有等,只是告诉服务员薯条好了后告诉我或者我过段时间来看看状态(好了没有),然后不等就跑去逛街了。这属于异步非阻塞。事实上,异步肯定是非阻塞的,因为异步 肯定要做其他事情了,做其他事情是不可能睡过去的,所以异步只能是非阻塞的。
注意,同步非阻塞形式实际上是效率低下的。想象一下你一边玩手机一边还需要时刻留意 着到底薯条有没有好,大脑频繁来回切换关注,很累,手机游戏也玩不好。如果把玩手机和观 察薯条状态看成是程序的两个操作,那么这个程序需要在两种不同的行为之间来回切换,效率 肯定是低下的;异步非阻塞形式则没有这样的问题,因为你不必再等薯条是否好了(以后会有人通知或过一段时间去主动看一下有没有好),可以尽情地去逛街或在其他安静的地方玩手机。 程序没有在两种不同的操作中来回频繁切换。
同步非阻塞虽然效率不高,但比同步阻塞高很多,同步阻塞除了傻等,其他任何事情都做 不了,因为“睡过去”了。
10.4 I/O 和 网 络I/O
I/O(Input/Output, 输入/输出)即数据的读取(接收)或写入(发送)操作,通常用户进 程中的一个完整IO 分为两阶段:用户进程空间 → 内核空间、内核空间 →设备空间(磁盘、网 络 等 ) 。IO 分内存IO、网 络IO 和磁盘IO 三种,本章我们讲的是网络IO。
Windows 中进程无法直接操作I/O 设备,必须通过系统调用请求内核来协助完成I/O 动作。 内核会为每个I/O 设备维护一个缓冲区。对于一个输入操作来说,进程IO 系统调用后,内核 会先看缓冲区中有没有相应的缓存数据,没有的话再到设备(比如网卡设备)中读取,因为设 备IO 一般速度较慢,需要等待;内核缓冲区有数据就直接复制到用户进程空间。所以, 一个 网络输入操作通常包括两个不同的阶段:
- (1)等待网络数据到达网卡,把数据从网卡读取到内核缓冲区,数据准备好。
- (2)从内核缓冲区复制数据到用户进程空间。
10.5 I/O模式
在 Windows 下,套接字有两种I/O(Input/Output, 输入输出)模式:阻塞模式(也称同 步模式)和非阻塞模式(也称异步模式)。默认创建的套接字属于阻塞模式的套接字。
10.5.1 阻塞模式
在阻塞模式下,在I/O 操作完成前,执行的操作函数一直等候而不会立即返回,该函数所 在的线程会阻塞在这里(线程进入阻塞状态)。相反,在非阻塞模式下,套接字函数会立即返 回,而不管I/O 是否完成,该函数所在的线程会继续运行。
在阻塞模式的套接字上,调用大多数Windows Sockets API函数都会引起线程阻塞,但并 不是所有Windows Sockets API以阻塞套接字为参数调用都会发生阻塞。例如,以阻塞模式的 套接字为参数调用bind() 、listen(函数时,函数会立即返回。
这里将可能阻塞套接字的Windows Sockets API调用分为以下4种。
- (1)输入操作:包括 recv() 、recvfrom() 、WSARecv() 和 WSARecvfrom()函数。以阻塞套接字为参数调用 该函数接收数据。如果此时套接字缓冲区内没有数据可读,那么调用线程在数据到来前一直阻塞。
- (2)输出操作:包括 send() 、sendto() 、WSASend ( 和WSASendto() 函数。以阻塞套接字为参数调用该函数 发送数据。如果套接字缓冲区没有可用空间,线程就会一直睡眠,直到有空间。
- (3)接受连接:包括 accept() 和WSAAcept() 函数。以阻塞套接字为参数调用该函数,等待接受对方的连接 请求。如果此时没有连接请求,线程就会进入阻塞状态。
- (4)外出连接:包括 connect()和WSAConnect()函数。对于TCP 连接,客户端以阻塞套接字为参数,调用 该函数向服务器发起连接。该函数在收到服务器的应答前不会返回。这意味着TCP 连接总会 等待至少到服务器的一次往返时间。
使用阻塞模式的套接字,开发网络程序比较简单,容易实现。当希望能够立即发送和接收 数据且处理套接字数量比较少的情况下,使用阻塞模式来开发网络程序比较合适。
阻塞模式套接字的不足表现为,在大量建立好的套接字线程之间进行通信时比较困难。
当 使用“生产者-消费者”模型开发网络程序时,为每个套接字分别分配一个读线程、 一个处理数据线程和一个用于同步的事件,这样无疑会加大系统的开销。其最大的缺点是当希望同时处理大量套接字时将无从下手,可扩展性很差。
总之,我们要时刻记住阻塞函数和非阻塞函数的重要区别:阻塞函数,通常指一旦调用了,线程就阻塞;非阻塞函数一旦调用,线程并不会挂,而是会返回一个错误码,表示结果还没有出来
10.5.2 非阻塞模式
而对于处于非阻塞模式的套接字,会马上返回而不去等待该I/O 操作完成。针对不同的模 式 ,Winsock 提供的函数也有阻塞函数和非阻塞函数。相对而言,阻塞模式比较容易实现,在 阻塞模式下,执行I/O 的 Winsock 调 用 ( 如send 和 recv) 一直到操作完成才返回。
10.6 I/O 模型
为什么要采用Socket I/O模型,而不直接使用Socket? 原因在于recv()方法是堵塞式的, 当多个客户端连接服务器时,其中一个socket 的 recv 调用时会产生堵塞,使其他链接不能继续。
这样我们又想到用多线程来实现,每个 socket 链接使用一个线程,这样效率十分低下, 根本不可能应对负荷较大的情况。于是便有了各种模型的解决方法,总之都是为了实现多个线程同时访问时不产生堵塞。
如果使用“同步”的方式(所有的操作都在一个线程内顺序执行完成)来通信,那么缺点 是很明显的:因为同步的通信操作会阻塞来自同一个线程的任何其他操作,只有这个操作完成 了之后,后续的操作才可以完成;
一个明显的例子就是在 MFC 的界面代码中直接使用阻塞 Socket调用代码,整个界面都会因此而阻塞,没有任何响应! 所以我们不得不为每一个通信的 Socket 都建立一个线程,很麻烦,所以要写高性能的服务器程序,要求通信一定是异步的。
各位读者肯定知道,可以使用“同步通信(阻塞通信)+多线程”的方式来改善同步阻塞 线程的情况。
想一下,我们好不容易实现了让服务器端在每一个客户端连入之后都启动一个新 的 Thread 和客户端进行通信,有多少个客户端,就需要启动多少个线程;但是这些线程都处 于运行状态,所以系统不得不在所有可运行的线程之间进行上下文切换。我们自己没有什么感 觉,但是CPU 就痛苦不堪了,因为线程切换是相当浪费CPU 时间的,如果客户端的连入线程 过多,就会弄得CPU 都忙着去切换线程了,根本没有多少时间去执行线程体,所以效率是非 常低下的。
在阻塞I/O 模式下,如果暂时不能接收数据,那么接收函数(比如recv/WSARecv) 不 会 立即返回,而是等到有数据可以接收时才返回;如果一直没有数据,该函数就会一直等待下去, 应用程序也就挂起了。
很显然,异步的接收方式更好一些,因为无法保证每次的接收调用总能 适时地接收到数据。而异步的接收方式也有其复杂之处,比如立即返回的结果并不总是成功收发数据,实际上很可能会失败,最多的失败原因是WSAEWOULDBLOCK 。 可 以 使 用 WSAGetLastError 函数得到发送和接收失败时的失败原因。这个失败原因较为特殊,也常出现, 它的意思是说要进行的操作暂时不能完成,如果在以后的某个时间再次执行该操作也许就会是成功的。如果发送缓冲区已满,这时调用 WSASend 函数就会出现这个错误。同理,如果接收缓冲区内没有内容,这时调用 WSARecv 也会得到同样的错误。这并不意味着发送和接收调 用会永远失败下去,而是在以后某个适当的时间,比如发送缓冲区有空间了、接收缓冲区有数 据了,再调用发送和接收操作就会成功了。那么什么时间是恰当的呢?这就是套接字10模型 产生的原因了,它的作用就是通知应用程序发送或接收数据的时间点到了,可以开始收发了。
在非阻塞模式下,Winsock 函数会立即返回。阻塞套接字的好处是使用简单,但是当需要处理多个套接字连接时,就必须创建多个线程,即典型的一个连接使用一个线程的问题,这给编程带来了许多不便。所以实际开发中使用最多的还是下面要讲述的非阻塞模式。
非阻塞模式比较复杂,为了实现套接字的非阻塞模式,微软提出了非阻碍套接字的5种I/O 模型:
- (1)选择模型,或称Select 模型,主要是利用Select 函数实现对I/O 的管理。
- (2)异步选择模型,或称WSAAsyncSelect 模型,允许应用程序以Windows 消息的方式 接收网络事件通知。
- (3)事件选择模型,也称WSAEventSelect 模型,类似于WSAAsynSelect 模型,两者最 主要的区别是在事件选择模型下网络事件发生时会被发送到一个事件对象句柄,而不是发送到 一个窗口。
- (4)重叠I/O 模型,可以要求操作系统传送数据,并且在传送完毕时通知。具体实现时, 可以使用事件通知或者完成例程两种方式分别实现重叠I/O 模型。重叠I/O(Overlapped I/O)模型比上述3种模型能达到更佳的系统性能。
- (5)完成端口模型,是最为复杂的一种I/O 模型,当然性能也是最强大的。当一个应用 程序同时需要管理很多个套接字时,可以采用这种模型,往往可以达到最佳的系统性能。
不同的模型,程序架构是不同的,相对而言,难度依次递增。强调一下,这5种模型都是 针对非阻塞模式。
参考书籍:《Visual C++ 2017网络编程实战》