高性能网络编程

963 阅读14分钟

高性能网络编程的关注点:

网络编程框架,本质上就是封装了socket套接字编程的细节,然后通过回调机制向应用程序代码提供网络服务,使得应用程序不感知套接字层,就能完成套接字间的网络传输。

①为了为应用程序提供更高的效率,网络编程框架往往会使用大量的线程。而线程的IO是非常慢的,往往是成为框架性能瓶颈的原因;引入多线程也会带来一系列的并发问题。从这些问题上,我们也可以感知到网络编程框架的核心就是,把应用层的代码复杂度扔给了操作系统。所以我们需要关注于操作系统层级的线程模型、多线程互斥锁的使用,减少并发给操作系统带来的性能损耗。

②网络编程框架就是封装了socket套接字编程嘛,所以网络编程框架也要关注于:如何更优雅地设计线程模型来提高套接字编程的效率,线程模型是如何与套接字编程结合的。

③网络编程的目标是实现网络通信,所以自然也要关注于网络传输中的网络分组,要着重于处理网络包在网络传输时的一系列问题。

image.png

socket函数

socket也是文件,是用于进程间通信的文件。那么socket函数就是对文件的操作。

服务端

socket():相当于打开一个普通文件,socket()返回一个socket描述符,唯一标识一个socket套接字。但这个socket还没有地址,所以需要使用bind()赋给socket一个地址。(每个进程都有一个数据结构task_struct,里面指向一个文件指针数组,指向内核中所有打开的文件的列表,而数组的下标就是文件描述符。所以socket描述符唯一标识一个socket套接字)

bind(new InetSocketAddress(port)):为socket描述符绑定一个IP+端口,此时操作系统内核会为这个端口创建SYN队列和ACCEPT队列。当一个网络包到来时,socket通过IP地址筛选它所监听的客户端/网卡,通过端口号匹配TCP头中的目标端口号,实现端对端的进程间通信。

listen():开启监听状态???我没用吧???(UDP不需要listen()和connect())

accept():接收客户端的连接请求,返回一个已连接socket套接字进行处理。如果此时有多个客户端同时发起连接,那么这些连接就会被放在ACCEPT队列里,accept()会从队列中取出一个连接,拷贝到用户态,分配给进程处理。如果想取出多个就需要调用多次accept()(socket()得到的是监听套接字,与channel的阻塞非阻塞联系一下?)

read():从socket描述符中读取数据。

客户端

socket():同服务端。客户端不需要bind(),随机分配即可

connect():客户端向服务端发出连接请求,需要指明要连接的IP和端口。然后发起三次握手,内核会给客户端分配一个临时的端口,一旦握手成功,服务端在accept()上阻塞的线程被唤醒。

write():向socket描述符中写入数据。

番外:网络分层中的socket:

上面说的进程间通信同是在同一个Linux上的两进程之间的通信。如果需要跨主机进行进程通信,就需要socket通信。socket通信是基于TCP/IP网络协议的,但它不属于网络分层中的任意一层,它是属于操作系统的概念。对于网络分层模型,从物理层到传输层都是在Linux内核态中,应用层是在用户态中,而用户态应用层和内核态的交互机制,就是通过socket系统调用!

一个socket监听主机上的一个端口。应用程序发送的数据包,通过socket发送给内核,内核会对数据包进行层层封装,从硬件网口发出。然后经过路径中的各种交换机、路由器等到达对端网络。数据包在对端网络中层层解封装,内核根据TCP头部中的端口号,通过socket接口最终发送给对端的应用程序。 那么两个进程可以同时监听同一个端口吗? 可以。服务器监听一个端口会经过:①创建socket套接字 ②将套接字bind绑定到具体的IP+Port ③调用listen开始在这个socket套接字上进行监听。而在bind绑定之前可以设置socket套接字选项,resueaddr选项就表明多个进程可以复用此bind函数中的IP+Prot。

那么TCP和UDP的套接字能够同时监听一个端口吗?

可以。其实端口号的唯一标识并不是端口号,而是端口号+协议名。所以将套接字bind绑定端口时会有个type参数,来标识不同的协议类型,使得不同的socket类型可以绑定到同一端口上。

channel

Selector.open()、Selector.register(...)

SelectionKey.channel()得到ServerSocketChannel

ServerSocketChannel.accept()得到SocketChannel)


I/O总纲

阻塞和非阻塞的区别:针对的是线程是否挂起,是否获取CPU时间片。

同步与异步的区别:针对线程是否要自己把数据读到应用程序缓冲区。

①阻塞:socket连接是阻塞的,操作socket连接(发起请求)的线程会被挂起。

②非阻塞:socket连接是非阻塞的,操作socket连接(发起请求)的线程不会丢失CPU时间片,会快速返回

③同步:线程自己读取数据,不断将数据从内核缓冲区读到应用程序缓冲区,读取数据期间线程无法脱身去处理其他逻辑。

④异步:操作系统内核会帮你把所有数据写入应用程序缓冲区,再通知线程来处理。


同步I/O

BIO:BIO实际上就是连接与线程捆绑(阻塞IO本质上是与进程捆绑),一个线程处理一个连接的全部生命周期。这就会导致操作系统的线程总量很大,而CPU调度时间是有限的,所以就导致了线程的大部分时间都在睡眠,就降低了CPU利用率。而且连接的生命周期可以分成两个阶段,等待消息准备好、消息处理,这样的同步阻塞模型就是把这两个阶段合二为一。所以线程得到CPU的时间片后往往会阻塞在等待消息准备好阶段,然后又睡眠(若channel为阻塞则线程也会阻塞,就不得不为每个channel都创建一个线程),导致大量的线程上下文切换,从而影响了CPU的利用率。所以就有了IO多路复用。

I/O多路复用

多路复用,意思就是多个网络间接的IO复用在了一个Selector复用器上,用户进程会阻塞在Selector上。与此同时内核会监视注册到selector上的socket连接,当连接中有数据准备好了selector就轮询到事件的发生。此后进程调用read()方法将channel中的就绪数据从内核拷贝到用户空间,而此过程仍然是阻塞的,所以I/O多路复用的同步IO!

它的本质是把连接生命周期的等待消息准备好、消息执行两个阶段分离。然后让1个线程处理所有连接的“等待消息准备好”,让其他线程处理连接的“消息执行”,并且设置“消息执行”的套接字为非阻塞,避免“等待消息准备好”阶段还没有处理,造成线程阻塞睡眠。

NIO -Java中的I/O多路复用

Reactor:事件驱动模型,也就是将事件驱动框架(事件监听与分发)和具体业务处理分离开来,事件驱动框架会为应用代码抽象出一个接口。对于应用代码而言(即读写线程池),它只会感知到socket中可读可写的事件,然后读取/发送socket中的数据进行业务逻辑处理。

①单Reactor线程模型:单个线程处理所有I/O操作。Selector上注册监听所有事件,事件就绪时,由单线程处理连接事件、读写事件。

②多Reactor线程模型:单个线程处理监听、分发、连接的I/O操作。Selector上注册监听所有事件。使用一个线程监听并分发所有事件,此线程自己处理就绪的连接事件,使用Worker线程池进行异步读写。

③主从Reactor线程模型:使用主Reactor线程监听所有事件并处理连接事件,然后把读就绪事件分发给其他的从Reactor线程,从Reactor线程监听到读写事件,使用线程池进行异步读写。

I/O复用函数

进程将读操作通过I/O复用函数,阻塞在函数操作上。这样系统内核就能帮我们侦测多个读操作是否处于就绪状态。

select与epoll都是使用1个线程处理所有连接的“等待消息准备好”,但select是监控并轮询所有连接(监听socket的文件描述符),在调用时会把用户态的所有socket拷贝到内核态,拿到其中报文到达的活跃连接返回给用户态,非常低效。

epoll:在进程内同时刻找到缓冲区或连接状态发生变化的所有TCP连接

而epoll直接在内核态维护了一个红黑树与一个链表,保存了其监控的所有socket。其中红黑树中存储所有连接,而链表中存储准备就绪的连接。那么“消息准备好时”事件就绪时,内核只会去活跃连接的链表中寻找事件就绪的socket连接并返回到用户态,执行效率就大大提升了。(RE:epoll为内核中断函数注册了一个回调函数。当某个socket句柄的中断到了,内核就把准备就绪的socket放入链表中)

epoll的ET触发模式与LT触发模式针对的是返回的连接如何能够准确一些。对于“可写但无法立即处理”的连接,epoll提供了两种模式。当socket上有事件就绪时,内核会把该socket放入就绪链表中。然后操作系统把这些准备就绪的socket拷贝到用户态,并情况准备就绪的链表。

但如果这个socket上的事件就绪但还有数据尚未到达时(当连接“可写状态”时,这个响应的内容有可能还在磁盘上正在读取呢,所以事件处理完成后还会有数据到达。“可读同理”),LT水平触发会把socket重新放回就绪链表,不断重新发送直至有可读数据,然后处理完此读事件。而ET边缘触发不管数据有没有到达连接,只有事件就绪就立即仅仅触发一次,不会管缓冲区中是否还有数据。

(补充:连接变为可读/可写、变为不可读/不可写时,ET触发;读写完成后又有了新数据/新空间,即连接只要处于可读/可写状态时,LT触发。若之后看不懂,则整理www.dazhuanlan.com/2020/01/18/…

应用场景:LT保证数据的完成输出,但内核会不断地进行用户态与内核态的转换,性能低;ET每次事件就绪,内核只会通知一次,性能更高,适用于大部分框架中。、

[非阻塞 + epoll + 同步 = 协程] 处理TCP多路复用

零拷贝:使用Direct Buffer,在JVM内存空间之外开辟了一个物理内存空间,内核与用户进程共享这个空间内的缓存数据,避免了读写操作时的内存拷贝。

异步IO

应用程序的线程数量并不是越多越好,若线程数量远大于CPU核数,在高并发的情况下会发生大量线程上下文切换,从而耗费时间。那么如何使用少量的线程来处理大量的请求呢?一种是使用请求队列暂存请求,然后用少量的线程去匹配请求队列并处理请求,这样可以最大化CPU的利用率。另一种就是避免线程的阻塞等待,即线程的某次请求需要等待外部服务时,发出请求后就继续去处理下一个请求,等响应回来后再通知该线程,执行后续的业务逻辑。这就是AIO模型,它的本质就是线程不去等待Socket通道上的数据,而是等待操作系统发出的数据到达通知。AIO可以有效地避免线程等待,大幅减少CPU在线程上下文切换上浪费的时间。

Netty中的IO模型

Netty并没有照搬Reactor模式,它的核心是EventLoop,监听网络事件并调用事件处理器进行处理。网络连接与EventLoop是多对一关系,而EventLoop与线程是一对一关系,所以一个网络连接只对应一个唯一的线程,即避免了各种并发问题(对于一个网络连接,读写操作都是单线程的???)。

协程

异步框架用非阻塞API把业务逻辑打乱到多个回调函数里,把本该由内核实现的请求切换工作交由用户态的回调代码以非阻塞的方式完成,通过多路复用实现高并发。然而这需要维护很多中间状态,存在风险。所以我们可以使用协程,在保持异步化运行机制的同时,用同步的方式写代码。

协程就是把异步框架中的两段函数,封装成一个阻塞的协程函数。当这个函数执行时,调用它的协程就会无感知地放弃执行权,由协程框架切换到其他就绪的协程继续执行。当这个函数满足执行条件时,协程框架再在合适的时机切换会它所在的协程继续执行。

协程原理:创建协程时,会在进程的堆中分配一块内存作为协程的栈(线程的栈有8M,而协程的栈通常只有几十KB),那协程就使用它自己的栈来记录方法栈的内容(模仿栈)和指令执行位置(模仿CPU寄存器),完成在用户态的协程间切换。所以协程不用关注自己何时被挂起,它直接使用代码来表示状态,大大提升了开发效率。说白了,协程就是只存在于用户态的线程,线程触发阻塞的系统调用后,实际上只是让协程休眠,线程再去调用其他的协程。

服务端的最大连接数 TCP的端口号占16位,而且每个客户端发起一个TCP连接请求时,都需要随机分配一个空闲的端口。也就是说,客户端的独占端口的,最多能发起65535个连接。

而服务端通常是监听在某个已经确定的端口(80)上来等待客户端的连接,所以对于TCP四元组,可变的是客户端的IP+端口。所以理论的最大连接数为2^48个(IP+port = 2^32*2^16)。当然,实际上服务端的TCP连接数不会达到2^48,因为有socket文件描述符的限制、内存的限制等等。