什么是IO
一句话总结
IO就是内存和硬盘的输入输出
I/O 其实就是 input 和 output 的缩写,即输入/输出。
那输入输出啥呢?
比如我们用键盘来敲代码其实就是输入,那显示器显示图案就是输出,这其实就是 I/O。
而我们时常关心的磁盘 I/O 指的是硬盘和内存之间的输入输出。
读取本地文件的时候,要将磁盘的数据拷贝到内存中,修改本地文件的时候,需要把修改后的数据拷贝到磁盘中。
网络 I/O 指的是网卡与内存之间的输入输出。
当网络上的数据到来时,网卡需要将数据拷贝到内存中。当要发送数据给网络上的其他人时,需要将数据从内存拷贝到网卡里。
那为什么都要跟内存交互呢?
我们的指令最终是由 CPU 执行的,究其原因是 CPU 与内存交互的速度远高于 CPU 和这些外部设备直接交互的速度。
因此都是和内存交互,当然假设没有内存,让 CPU 直接和外部设备交互,那也算 I/O。
总结下:I/O 就是指内存与外部设备之间的交互(数据拷贝)。
好了,明确什么是 I/O 之后,让我们来揭一揭 socket 通信内幕~
如何通信
socket
socket 创建
首先服务端需要先创建一个 socket。在 Linux 中一切都是文件,那么创建的 socket 也是文件,每个文件都有一个整型的文件描述符(fd)来指代这个文件。
int socket(int domain, int type, int protocol);
- domain:这个参数用于选择通信的协议族,比如选择 IPv4 通信,还是 IPv6 通信等等
- type:选择套接字类型,可选字节流套接字、数据报套接字等等。
- protocol:指定使用的协议。这个 protocol 通常可以设为 0 ,因为由前面两个参数可以推断出所要使用的协议。
比如socket(AF_INET, SOCK_STREAM, 0);
,表明使用 IPv4 ,且使用字节流套接字,可以判断使用的协议为 TCP 协议。
这个方法的返回值为创建的 socket 的 fd。
bind 绑定
现在我们已经创建了一个 socket,但现在还没有地址指向这个 socket。
众所周知,服务器应用需要指明 IP 和端口,这样客户端才好找上门来要服务,所以此时我们需要指定一个地址和端口来与这个 socket 绑定一下。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数里的 sockfd 就是我们创建的 socket 的文件描述符,执行了 bind 参数之后我们的 socket 距离可以被访问又更近了一步。
listen 监听
执行了 socket、bind 之后,此时的 socket 还处于 closed 的状态,也就是不对外监听的,然后我们需要调用 listen 方法,让 socket 进入被动监听状态,这样的 socket 才能够监听到客户端的连接请求。
int listen(int sockfd, int backlog);
传入创建的 socket 的 fd,并且指明一下 backlog 的大小。
这个 backlog 我查阅资料的时候,看到了三种解释:
- socket 有一个队列,同时存放已完成的连接和半连接,backlog为这个队列的大小。
- socket 有两个队列,分别为已完成的连接队列和半连接队列,backlog为这个两个队列的大小之和。
- socket 有两个队列,分别为已完成的连接队列和半连接队列,backlog仅为已完成的连接队列大小。
解释下什么叫半连接
我们都知道 TCP 建立连接需要三次握手,当接收方收到请求方的建连请求后会返回 ack,此时这个连接在接收方就处于半连接状态,当接收方再收到请求方的 ack 时,这个连接就处于已完成状态:
所以上面讨论的就是这两种状态的连接的存放问题。
我查阅资料看到,基于 BSD 派生的系统的实现是使用的一个队列来同时存放这两种状态的连接, backlog 参数即为这个队列的大小。
而 Linux 则使用两个队列分别存储已完成连接和半连接,且 backlog 仅为已完成连接的队列大小
accept 服务端连接
现在我们已经初始化好监听套接字了,此时会有客户端连上来,然后我们需要处理这些已经完成建连的连接。
从上面的分析我们可以得知,三次握手完成后的连接会被加入到已完成连接队列中去。
这时候,我们就需要从已完成连接队列中拿到连接进行处理,这个拿取动作就由 accpet 来完成。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
这个方法返回的 int 值就是拿到的已完成连接的 socket 的文件描述符,之后操作这个 socket 就可以进行通信了。
如果已完成连接队列没有连接可以取,那么调用 accept 的线程会阻塞等待。
至此服务端的通信流程暂告一段落,我们再看看客户端的操作。
connect 客户端连接
客户端也需要创建一个 socket,也就是调用 socket(),这里就不赘述了,我们直接开始建连操作。
客户端需要与服务端建立连接,在 TCP 协议下开始经典的三次握手操作,再看一下上面画的图:
客户端创建完 socket 并调用 connect 之后,连接就处于 SYN_SEND 状态,当收到服务端的 SYN+ACK 之后,连接就变为 ESTABLISHED 状态,此时就代表三次握手完毕。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
调用connect需要指定远程的地址和端口进行建连,三次握手完毕之后就可以开始通信了。
客户端这边不需要调用 bind 操作,默认会选择源 IP 和随机端口。
建立连接操作总结
用一幅图来小结一下建连的操作:
可以看到这里的两个阻塞点:
- connect:需要阻塞等待三次握手的完成。
- accept:需要等待可用的已完成的连接,如果已完成连接队列为空,则被阻塞。
read、write
连接建立成功之后,就能开始发送和接收消息了,我们来看一下
read 为读数据,从服务端来看就是等待客户端的请求,如果客户端不发请求,那么调用 read 会处于阻塞等待状态,没有数据可以读,这个应该很好理解。
write 为写数据,一般而言服务端接受客户端的请求之后,会进行一些逻辑处理,然后再把结果返回给客户端,这个写入也可能会被阻塞。
这里可能有人就会问 read 读不到数据阻塞等待可以理解,write 为什么还要阻塞,有数据不就直接发了吗?
因为我们用的是 TCP 协议,TCP 协议需要保证数据可靠地、有序地传输,并且给予端与端之间的流量控制。
所以说发送不是直接发出去,它有个发送缓冲区,我们需要把数据先拷贝到 TCP 的发送缓冲区,由 TCP 自行控制发送的时间和逻辑,有可能还有重传什么的。
如果我们发的过快,导致接收方处理不过来,那么接收方就会通过 TCP 协议告知:别发了!忙不过来了。发送缓存区是有大小限制的,由于无法发送,还不断调用 write 那么缓存区就满了,满了就不然你 write 了,所以 write 也会发生阻塞。
综上,read 和 write 都会发生阻塞。
总结:为什么网络 I/O 会被阻塞?——io模型
因为建连和通信涉及到的 accept、connect、read、write 这几个方法都可能会发生阻塞。
阻塞会占用当前执行的线程,使之不能进行其他操作,并且频繁阻塞唤醒切换上下文也会导致性能的下降。
由于阻塞的缘故,起初的解决的方案就是建立多个线程,但是随着互联网的发展,用户激增,连接数也随着激增,需要建立的线程数也随着一起增加,到后来就产生了 C10K 问题。
服务端顶不住了呀,咋办?
优化呗!
所以后来就弄了个非阻塞套接字,然后 I/O多路复用、信号驱动I/O、异步I/O。
下篇我们就来好好盘盘,这几种 I/O 模型!
基础知识介绍——内核态与用户态
上篇我们已经搞懂了 socket 的通信内幕,也明白了网络 I/O 确实会有很多阻塞点,阻塞 I/O 随着用户数的增长只能利用增加线程的方式来处理更多的请求,而线程不仅会占用内存资源且太多的线程竞争会导致频繁地上下文切换产生巨大的开销。
因此,阻塞 I/O 已经不能满足需求,所以后面大佬们不断地优化和演进,提出了多种 I/O 模型。
在 UNIX 系统下,一共有五种 I/O 模型,今天我们就来盘一盘它!
不过在介绍 I/O 模型之前,我们需要先了解一下前置知识。
内核态与用户态
我们的电脑可能同时运行着非常多的程序,这些程序分别来自不同公司。
谁也不知道在电脑上跑着的某个程序会不会发疯似得做一些奇怪的操作,比如定时把内存清空了。
因此 CPU 划分了非特权指令和特权指令,做了权限控制,一些危险的指令不会开放给普通程序,只会开放给操作系统等特权程序。
你可以理解为我们的代码调用不了那些可能会产生“危险”操作,而操作系统的内核代码可以调用。
这些“危险”的操作指:内存的分配回收,磁盘文件读写,网络数据读写等等。
如果我们想要执行这些操作,只能调用操作系统开放出来的 API ,也称为系统调用。
这就好比我们去行政大厅办事,那些敏感的操作都由官方人员帮我们处理(系统调用),所以道理都是一样的,目的都是为了防止我们(普通程序)乱来。
这里又有两个名词:
- 用户空间
- 内核空间。
我们普通程序的代码是跑在用户空间上的,而操作系统的代码跑在内核空间上,用户空间无法直接访问内核空间的。当一个进程运行在用户空间时就处于用户态,运行在内核空间时就处于内核态。
当处于用户空间的程序进行系统调用,也就是调用操作系统内核提供的 API 时,就会进行上下文的切换,切换到内核态中,也时常称之为陷入内核态。
那为什么开头要先介绍这个知识点呢?
因为当程序请求获取网络数据的时候,需要经历两次拷贝:
- 程序需要等待数据从网卡拷贝到内核空间。
- 因为用户程序无法访问内核空间,所以内核又得把数据拷贝到用户空间,这样处于用户空间的程序才能访问这个数据。
介绍这么多就是让你理解为什么会有两次拷贝,且系统调用是有开销的,因此最好不要频繁调用。
然后我们今天说的 I/O 模型之间的差距就是这拷贝的实现有所不同!
今天我们就以 read 调用,即读取网络数据为例子来展开 I/O 模型。
发车!
IO模型
同步阻塞模型
假如A在河边钓鱼的时候,非常的专心,生怕鱼儿溜掉,故此,A就一直盯着鱼竿,一直等着鱼儿上钩,专心的做这一件事情,直到鱼儿上钩,才结束这个动作,这就是阻塞IO。在内核把数据准备好之前,系统调用会一直处于阻塞状态。
当用户程序的线程调用 read 获取网络数据的时候,首先这个数据得有,也就是网卡得先收到客户端的数据,然后这个数据有了之后需要拷贝到内核中,然后再被拷贝到用户空间内,这整一个过程用户线程都是被阻塞的。
假设没有客户端发数据过来,那么这个用户线程就会一直阻塞等着,直到有数据。即使有数据,那么两次拷贝的过程也得阻塞等着。
所以这称为同步阻塞 I/O 模型。
它的优点很明显,简单。调用 read 之后就不管了,直到数据来了且准备好了进行处理即可。
缺点也很明显,一个线程对应一个连接,一直被霸占着,即使网卡没有数据到来,也同步阻塞等着。
我们都知道线程是属于比较重资源,这就有点浪费了。
所以我们不想让它这样傻等着。
于是就有了同步非阻塞 I/O。
同步非阻塞 I/O
假如B也在河边钓鱼,B不想像A一样把所有的时间都花在等鱼儿上钩这件事情上,所以他的做法就是在等待鱼儿上钩的同时,自己也可以看看书,刷刷小编的博客,聊天等等。但是B也不是就不管鱼儿了,他会每隔一段固定时间都来看一下,有没有鱼儿上钩,如果有鱼儿上钩,他就结束这个动作,这就是非阻塞IO。
非阻塞IO往往需要程序员循环的方式反复尝试读取文件描述符,这个过程称为轮询,这对于cpu来说的话是较大的浪费,一般只有特定的场景下才能使用。
从图中我们可以很清晰的看到,同步非阻塞I/O 基于同步阻塞I/O 进行了优化:
在没数据的时候可以不再傻傻地阻塞等着,而是直接返回错误,告知暂无准备就绪的数据!
这里要注意,从内核拷贝到用户空间这一步,用户线程还是会被阻塞的。
这个模型相比于同步阻塞 I/O 而言比较灵活,比如调用 read 如果暂无数据,则线程可以先去干干别的事情,然后再来继续调用 read 看看有没有数据。
但是如果你的线程就是取数据然后处理数据,不干别的逻辑,那这个模型又有点问题了。
等于你不断地进行系统调用,如果你的服务器需要处理海量的连接,那么就需要有海量的线程不断调用,上下文切换频繁,CPU 也会忙死,做无用功而忙死。
那怎么办?
于是就有了I/O 多路复用。
多路复用
假如D也在河边钓鱼,但是D是一个土豪,他一个人就拿了好多鱼竿摆在哪里,这样很明显就增加了鱼儿上钩的机会。他只需要不断地查看每个鱼竿是否有鱼儿上钩就行了,提高了效率。 实际上最核心在于IO多路转接能够同时等待多个文件描述符的就绪状态。
从图上来看,好像和上面的同步非阻塞 I/O 差不多啊,其实不太一样,线程模型不一样。
既然同步非阻塞 I/O 在太多的连接下频繁调用太浪费了, 那就招个专员吧。
这个专员工作就是管理多个连接,帮忙查看连接上是否有数据已准备就绪。
也就是说,可以只用一个线程查看多个连接是否有数据已准备就绪。
具体到代码上,这个专员就是 select ,我们可以往 select 注册需要被监听的连接,由 select 来监控它所管理的连接是否有数据已就绪,如果有则可以通知别的线程来 read 读取数据,这个 read 和之前的一样,还是会阻塞用户线程。
这样一来就可以用少量的线程去监控多条连接,减少了线程的数量,降低了内存的消耗且减少了上下文切换的次数,很舒服。
想必到此你已经理解了什么叫 I/O 多路复用。
所谓的多路指的是多条连接,复用指的是用一个线程就可以监控这么多条连接。
看到这,你再想想,还有什么地方可以优化的?
信号驱动式IO
假如C也在河边钓鱼,他认为A、B不够聪明,故此,他想了一种办法,就是在鱼竿上挂上了一个铃铛,当有鱼儿上钩的时候,铃铛就会被触发,发出响声,他就可以过去将鱼儿钓上来了。信号驱动IO模型,应用进程告诉内核:当数据报准备好的时候,给我发送一个信号,对SIGIO信号进行捕捉,并且调用我的信号处理函数来获取数据报。
上面的 select 虽然不阻塞了,但是他得时刻去查询看看是否有数据已经准备就绪,那是不是可以让内核告诉我们数据到了而不是我们去轮询呢?
信号驱动 I/O 就能实现这个功能,由内核告知数据已准备就绪,然后用户线程再去 read(还是会阻塞)。
听起来是不是比 I/O 多路复用好呀?那为什么好像很少听到信号驱动 I/O?
为什么市面上用的都是 I/O 多路复用而不是信号驱动?
因为我们的应用通常用的都是 TCP 协议,而 TCP 协议的 socket 可以产生信号事件有七种。
也就是说不仅仅只有数据准备就绪才会发信号,其他事件也会发信号,而这个信号又是同一个信号,所以我们的应用程序无从区分到底是什么事件产生的这个信号。
那就麻了呀!
所以我们的应用基本上用不了信号驱动 I/O,但如果你的应用程序用的是 UDP 协议,那是可以的,因为 UDP 没这么多事件。
因此,这么一看对我们而言信号驱动 I/O 也不太行。
异步 I/O
假如E也想钓鱼,但是他又有点忙,所以他雇佣了一个人专门帮他看着鱼竿,一旦有鱼儿上钩,就让这个人通知他,他过来将鱼儿钓上来。由内核在数据拷贝完成时, 通知应用程序(信号驱动是告诉应用程序何时可以开始拷贝数据).
这一次我们雇了一个钓鱼高手。他不仅会钓鱼,还会在鱼上钩之后给我们发短信,通知我们鱼已经准备好了。我们只要委托他去抛竿,然后就能跑去干别的事情了,直到他的短信。我们再回来处理已经上岸的鱼。
信号驱动 I/O 虽然对 TCP 不太友好,但是这个思路对的:往异步发展,但是它并没有完全异步,因为其后面那段 read 还是会阻塞用户线程,所以它算是半异步。
因此,我们得想下如何弄成全异步的,也就是把 read 那步阻塞也省了。
其实思路很清晰:让内核直接把数据拷贝到用户空间之后再告知用户线程,来实现真正的非阻塞I/O!
所以异步 I/O 其实就是用户线程调用 aio_read ,然后包括将数据从内核拷贝到用户空间那步,所有操作都由内核完成,当内核操作完毕之后,再调用之前设置的回调,此时用户线程就拿着已经拷贝到用户控件的数据可以继续执行后续操作。
在整个过程中,用户线程没有任何阻塞点,这才是真正的非阻塞I/O。
那么问题又来了:
为什么常用的还是I/O多路复用,而不是异步I/O?
因为 Linux 对异步 I/O 的支持不足,你可以认为还未完全实现,所以用不了异步 I/O。
这里可能有人会说不对呀,像 Tomcat 都实现了 AIO的实现类,其实像这些组件或者你使用的一些类库看起来支持了 AIO(异步I/O),实际上底层实现是用 epoll 模拟实现的。
而 Windows 是实现了真正的 AIO,不过我们的服务器一般都是部署在 Linux 上的,所以主流还是 I/O 多路复用。
至此,想必你已经清晰五种 I/O 模型是如何演进的了。
下篇,我将讲讲谈到网络 I/O 经常会伴随的几个容易令人混淆的概念:同步、异步、阻塞、非阻塞。