编辑
2023-12-03
操作系统
0
请注意,本文编写于 320 天前,最后修改于 77 天前,其中某些信息可能已经过时。

目录

网络进程中的通讯
Socket
接口函数
进程空间
阻塞与非阻塞
同步与异步
五种IO模型
阻塞IO
非阻塞IO
多路复用IO
select
poll
epoll:
信号驱动 I/O
异步IO
Node.js并发模型
核心概念
Node.js 并发模型的工作流程
事件循环的各个阶段
事件循环的执行顺序

网络进程中的通讯

本地的进程间通信(IPC)有很多种方式,但可以概括为以下4类:

  • 消息传递(管道、FIFO、消息队列)
  • 同步(互斥量、条件变量、读写锁、文件和写记录锁、信号量)
  • 共享内存(匿名的和具名的)
  • 远程过程调用(Solaris门和Sun RPC)

在本地环境中,我们可以通过进程PID来唯一标识一个进程,但是在网络通信过程中,首要问题在于如何唯一标识一个进程,这是通信的基础。TCP/IP协议族为我们解决了这个问题,通过网络层的“IP地址”唯一标识主机以及传输层的“协议+端口”唯一标识主机中的应用程序(进程)。这样,利用三元组(IP地址,协议,端口),我们就能够唯一标识网络中的进程,从而实现进程间的通信。这种方式使得各个进程可以利用这个标识与其他进程进行交互。目前使用TCP/IP协议的应用程序通常采用应用编程接口:UNIX BSD的套接字(socket)和UNIX System V的TLI(已经被淘汰),来实现网络进程之间的通信。就目前而言,几乎所有的应用程序都是采用socket。

Socket

Socket译为套接字,是计算机网络中进程间进行双向通信的端点的抽象。Socket 既可以用于在同一台计算机上的进程间通信,也可以用于不同计算机之间的通信,是网络编程中不可或缺的核心概念之一。通过操作系统为应用程序提供的 Socket接口,应用程序能够创建、发送和接收数据,实现网络通信的功能。一个 Socket 由IP地址和端口组成,即:Socket 地址 = IP地址 : 端口号。

在Unix/Linux系统中,一切皆文件的哲学使得Socket可以被视作一种特殊的文件。这种思想意味着无论是网络通信还是文件操作,都可以采用统一的模式:打开(open)文件/Socket,进行读写操作(write/read),最后关闭(close)。Socket的引入正是为了在网络通信中实现这种模式。通过Socket函数,我们可以对Socket进行各种操作,包括打开、读写IO和关闭等。这种设计使得网络编程与文件操作具有一致的接口,极大地简化了程序员的工作

实现网络通信确实需要一对Socket,一个在客户端运行,称为Client Socket;另一个在服务器端运行,称为Server Socket。这两个Socket通过各自的功能来实现通信:Client Socket负责发起连接并发送请求,而Server Socket则负责监听并接受这些连接请求,并响应客户端的请求。Socket 之间的连接过程可以分为三个步骤:

  • 服务器监听
  • 客户端连接
  • 连接确认

缓冲区

每个Socket在内核中会分配两个缓冲区:输入缓冲区和输出缓冲区。

  • 当通过Socket发送数据时,数据会被写入到输出缓冲区中,而不会立即传输到网络中,TCP协议会负责将输出缓冲区中的数据传输到目标主机。这种设计可以提高效率,将数据先写入缓冲区可以使得应用程序不必等待网络传输的完成。
  • 类似地,当通过Socket接收数据时,数据会被读取到输入缓冲区中,而应用程序可以从输入缓冲区中读取数据,而不必直接从网络中读取。

接口函数

介绍几个基本的socket接口函数

  • socket:创建一个socket描述符(socket descriptor),它唯一标识一个socket。
  • bind:把一个地址族中的特定地址赋给socket。
  • listen:监听这个socket。
  • connect:客户端调用该函数来建立与TCP服务器的连接。
  • accept:TCP服务器监听到客户端发送的连接请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了。
  • read:负责从fd中读取内容
  • write:将buf中的字节内容写入文件描述符fd。

进程空间

操作系统的进程空间通常被划分为用户空间(User Space)和内核空间(Kernel Space),它们具有不同的执行权限和访问能力。

大多数的应用程序代码是在用户空间中执行,而用户空间的代码通常无法直接访问底层硬件或操作系统的关键功能。如果应用程序需要执行诸如设备IO操作、文件操作或网络通信等系统级操作,它必须通过系统调用(System Call)来请求操作系统代表其执行这些任务。

系统调用充当了用户空间与内核空间之间的桥梁,它提供了一种安全可靠的方式,使得应用程序可以间接地访问操作系统的核心功能。当应用程序发起系统调用时,操作系统会将控制权从用户空间切换到内核空间,并以内核特权的身份来执行相应的操作,比如从输入缓冲区中拷贝数据到用户缓冲区。

因此,系统调用在操作系统中扮演着非常重要的角色,它允许应用程序利用操作系统的强大功能,同时保证了系统的安全性和稳定性。

阻塞与非阻塞

阻塞与非阻塞,用于描述调用者在等待返回结果时的状态,是用户行为。

  • 阻塞:调用者发起请求后,会一直等待返回结果,这期间当前线程会被挂起(阻塞)。
  • 非阻塞:调用者发起请求后,会立刻返回,当前线程也不会阻塞。该调用不会立刻得到结果,调用者需要定时轮询查看处理状态。

同步与异步

同步与异步,用于描述调用结果的返回机制(或者叫通信机制)。

  • 同步:调用者发起请求后,会一直等待返回结果,即由调用者主动等待这个调用结果。
  • 异步:调用者发起请求后,会立刻返回,但不会立刻得到这个结果,而是由被调者在执行结束后主动通知(如 Callback)调用者。

五种IO模型

Linux 系统为我们提供五种可用的 IO 模型,分别是以下五种,其中前面四种都属于同步IO。 需要记住的是上述前四种IO都是同步IO。对于一次read IO访问,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

同步和异步的主要区别在于谁负责拷贝数据:同步方式下由用户线程将数据拷贝到用户空间;异步方式下由内核负责将数据拷贝到用户空间,拷贝完成后会通知用户线程或者调用用户线程注册的回调函数进行后续处理。

阻塞和非阻塞的主要区别在于是否需要等待完成:阻塞和非阻塞主要是针对同步方式,阻塞是指IO操作需要彻底完成后才返回到用户空间;而非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。

  • 阻塞IO
  • 非阻塞IO
  • 多路复用IO
  • 信号驱动IO
  • 异步IO

阻塞IO

传统IO模型。当用户线程发起IO请求后,需要等待将socket中的数据完全被读取到用户buffer换从去后,才继续处理接收的数据。这期间,用户线程是被阻塞的,无法执行其他任务,不能做任何事情,这样对CPU的资源利用率是不够的。

非阻塞IO

默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。当用户线程在发起IO请求后,可以立即返回。但为了获得数据,用户线程需要不断地询问内核数据是否就绪,也就是说非阻塞IO不会交出CPU,而会一直占用CPU。

虽然用户线程每次发起IO请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,消耗了大量的CPU的资源。一般很少直接使用这种模型,而是在其他IO模型中使用非阻塞IO这一特性。

因此,一般很少直接使用纯粹的非阻塞IO模型。相反,非阻塞IO通常会结合其他IO模型来使用,比如IO多路复用(IO Multiplexing)模型,如select、poll、epoll等。在这些模型中,可以使用非阻塞IO来达到异步的效果,当没有数据准备好时,应用程序可以立即返回而不会被阻塞,然后通过IO多路复用的方式来等待多个IO事件的到达,从而有效地利用CPU资源并降低系统开销。

多路复用IO

多路复用是一种高效管理多个 IO 操作的技术。通过这种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。它主要的好处是可以避免同步非阻塞IO模型中轮询等待的问题,可达到在同一个线程内同时监控多个文件描述符(网络套接字),处理多个IO请求的目的。它是操作系统级别的,可以提高系统的并发处理能力。

select、poll和epoll都是Linux基于这种机制提供的IO复用方式。他们本质上都是同步IO,因为这些就绪的IO上的数据都由用户线程进行拷贝。

select

  • select是最古老的IO多路复用机制之一,它使用一个fd_set集合来保存待检测的文件描述符,当调用select函数时,内核会遍历整个fd_set集合,检查每个文件描述符对应的IO状态,然后返回就绪的文件描述符集合。
  • 特点:fd_set集合是一个固定大小的数据结构,当文件描述符数量很大时,需要通过编程限制其大小,造成性能损耗;并且每次调用select函数都需要将fd_set集合从用户态拷贝到内核态,效率较低。

poll

  • poll是对select的改进(但本质相同),它使用一个pollfd结构数组来保存待检测的文件描述符,通过调用poll函数,内核会遍历整个pollfd数组,检查每个文件描述符的IO状态,然后返回就绪的文件描述符集合。
  • 特点:相对于select,poll没有固定大小的限制,编程更加方便;并且由于pollfd数组是通过指针传递给内核,因此不需要像select那样进行数据的拷贝,效率较高。

epoll:

  • epoll是Linux特有的IO多路复用机制,它引入了事件驱动的设计思想,使用红黑树和双链表来保存待检测的文件描述符,并允许用户空间通过epoll_ctl函数向内核注册或注销感兴趣的事件。
  • 特点:相比select和poll,epoll在文件描述符数量很大时拥有更好的性能表现,它没有描述符个数限制。它采用了一种"就绪队列"的方式,只将就绪的文件描述符放入一个内核中的就绪队列中,从而避免了遍历整个文件描述符集合的开销。

几种模式对比

epoll的优势非常明显,几乎没有描述符数量的限制,并发支持完美,不会随着socket的增加而降低效率,也不用在内核空间和用户空间之间做无效的copy操作。

信号驱动 I/O

当用户线程发起一个IO请求操作后,会给对应的socket注册一个信号函数,然后用户线程会继续执行,当内核数据就绪时会发送一个信号给用户线程,用户线程接收到信号之后,便在信号函数中调用IO读写操作来进行实际的IO请求操作。这个一般用于UDP中,对TCP套接口几乎是没用的,因为TCP是双工的,它的信号产生过于频繁,并且信号的出现几乎没有告诉我们发生了什么事情。

异步IO

当用户线程发起一个IO请求后,会立即返回。当内核中数据完全准备后,会复制到用户空间,会产生一个信号来通知应用进程。

Node.js并发模型

Node.js 的并发模型主要基于事件驱动架构和单线程事件循环。尽管 Node.js 是单线程的,但它能够高效地处理并发请求,特别是在 I/O 密集型任务中表现出色。下面是对 Node.js 并发模型的详细介绍:

核心概念

  • 单线程 Node.js 使用单线程来处理所有请求,这意味着所有 JavaScript 代码在同一个线程中运行。这种设计避免了多线程编程中的复杂性,如线程同步和数据竞争。
  • 事件循环 事件循环是 Node.js 并发模型的核心。它不断地检查事件队列,执行回调函数,并处理 I/O 操作。事件循环使得 Node.js 能够在单线程中高效地处理大量并发请求。
  • 事件驱动 Node.js 的事件驱动架构允许应用程序通过事件和回调函数来处理异步操作。当一个异步操作完成时,相关的回调函数会被添加到事件队列中,等待事件循环执行。
  • 非阻塞 I/O Node.js 的所有 I/O 操作(如文件读写、网络请求)都是非阻塞的。这意味着 I/O 操作会立即返回,程序可以继续执行其他操作,而不需要等待 I/O 操作完成。当 I/O 操作完成时,相关的回调函数会被触发。
  • 回调函数 回调函数是 Node.js 处理异步操作的主要方式。一个异步操作开始时,程序传递一个回调函数,当操作完成时,这个回调函数会被调用。
  • 异步编程 Node.js 提供了多种异步编程方式,包括回调函数、Promise、async/await 等,使得编写和管理异步代码更加方便和直观。

Node.js 并发模型的工作流程

  • 初始化:Node.js 启动并初始化事件循环,开始监听事件。
  • 执行脚本:Node.js 运行主脚本,注册事件处理程序和异步操作。
  • 事件循环:事件循环不断检查事件队列,执行回调函数,并处理异步操作。

事件循环的各个阶段

Node.js 的事件循环分为多个阶段,每个阶段都有特定的任务:

  • Timers:执行 setTimeout 和 setInterval 的回调。
  • I/O callbacks:执行一些延迟到下一个循环迭代的 I/O 回调。
  • Idle, prepare:仅供内部使用。
  • Poll:检索新的 I/O 事件;执行与 I/O 相关的回调(几乎所有回调都在这个阶段执行),除非事件队列为空。
  • Check:执行 setImmediate 的回调。
  • Close callbacks:执行一些关闭回调,如 socket.on('close', ...)。 示例代码 下面是一个简单的示例,展示了 Node.js 的并发模型:
javascript
const fs = require('fs'); // 异步读取文件 fs.readFile('example.txt', 'utf8', (err, data) => { if (err) { console.error(err); return; } console.log(data); }); // 设置定时器 setTimeout(() => { console.log('Timeout executed'); }, 1000); // 立即执行 setImmediate(() => { console.log('Immediate executed'); }); console.log('Script start');

解释

fs.readFile:这是一个异步文件读取操作,立即返回并将回调函数注册到事件队列中。

setTimeout:设置一个定时器,1 秒后执行回调函数。

setImmediate:将回调函数注册到 check 阶段,立即执行。

console.log('Script start'):立即执行,输出 "Script start"。

事件循环的执行顺序

  • 主脚本:首先执行主脚本中的同步代码,输出 "Script start"。
  • 异步操作:将 fs.readFile、setTimeout 和 setImmediate 回调注册到事件队列中。
  • 事件循环:事件循环开始执行,首先执行 setImmediate 回调,输出 "Immediate executed"。然后执行 fs.readFile 回调(假设文件读取非常快),输出文件内容。最后执行 setTimeout 回调,输出 "Timeout executed"。

Node.js 的并发模型基于单线程事件循环和异步 I/O 操作,使得它能够高效地处理大量并发请求。通过事件驱动架构和各种异步编程方式,开发者可以编写高性能的网络应用和服务器。尽管 Node.js 是单线程的,但它的设计使得它在 I/O 密集型任务中表现出色。

本文作者:sora

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!