秦皇岛哪家公司网站建设好,南宁工程造价建设信息网站,四级作文模板万能,餐饮类网站模板什么是IO 一句话总结 IO就是内存和硬盘的输入输出 I/O 其实就是 input 和 output 的缩写#xff0c;即输入/输出。
那输入输出啥呢#xff1f;
比如我们用键盘来敲代码其实就是输入#xff0c;那显示器显示图案就是输出#xff0c;这其实就是 I/O。
而我们时常关心的磁盘…什么是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 状态当收到服务端的 SYNACK 之后连接就变为 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 经常会伴随的几个容易令人混淆的概念同步、异步、阻塞、非阻塞。