go网络编程

网络编程

Socket API

因为高级语言大多都屏蔽了底层网络编程的细节和复杂性,所以为了真正了解到一些,语言的网络库的本质,先来学习一下 基本的socket api, 这里不介绍 unix域socket,socket_row,datagram_socket,重点关注steam_socket

TCP socket的API调用流程大概是这样的
   socket(server)                    socket(client)
    
        socket(2)                       socket(2)
           |                              |
        bind(2)                           |
           |                              |
        listen(2)                         |
           |                              |
        accept(2)                         |
           |                              |
           | <------------------------- connect(2)
           |                              |
 recv(2)/read(3) <--------------------- send(2)/write(3)
           |                              |
 send(2)/write(3) ---------------------> recv(2)/read(3)
           |                              |
        close(2)                         close(2)
  • socket函数创建一个套接字
// domain 套接字域:AF_INET,AF_INET6
// type 套接字类型:SOCK_STREAM,SOCK_DGRAM
// protocol 协议类型,对于TCP或UDP,都可以指定为0,因为type已经能确定协议类型
// 成功返回文件描述符,失败返回-1, 并设置errno
int socket(int domain, int type, int protocol);
  • bind将一个套接字绑定到一个地址上
// sockfd 就是由socket创建出来的套接字
// addr socket地址,sockaddr_in或socketaddr_in6
// 成功返回0,失败返回-1, 并设置errno
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • listen监听进来的连接
// sockfd 就是已经bind的套接字
// backlog 内核监听队列的最大长度
int listen(int sockfd, int backlog);

backlog的含义有点复杂,因为一个socket在被调用listen后,到accept前是要经历一个中间状态(SYN_RCVD)的,所以协议栈需要一个队列来维护这个状态,在linux2.2版本之前backlog指的是处于半连接(SYN_RCVD)和完全连接(ESTABLISHED)的socket的上限,但自2.2版本之后它只表示处于完全连接socket的上限。由/proc/sys/net/ipv4/tcp_max_syn_backlog内核参数定义。当客户端连接的时候,服务器可能正在处理其他逻辑而未调用accept接受连接,此时会导致这个连接被挂起,内核维护挂起的连接队列,backlog则指定这个队列的长度,accept函数从队列中取出连接请求并接收它,然后这个连接就从挂起队列移除。如果队列未满,客户端调用connect马上成功,如果满了会给客户一个ECONNREFUSED错误

  • accept 接收一个连接请求

如果没有连接则会一直阻塞直到有连接进来。得到客户端的fd之后,就可以调用read, write函数和客户端通讯,读写方式和其他I/O类似

// 接收成功后,客户端的地址信息存在addr中
// 如果成功返回客户端的fd,如果失败返回-1,错误信息记录在errno
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • connect 客户端向服务器发起连接
// 如果成功返回0,如果失败返回-1,错误码在errno,连接成功后,就可以通过read, write和服务器通信。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

  • close/shutdown 客户端向服务器发起连接
// sockfd是待关闭的socket
int close(int sockfd);

// sockfd是待关闭的socket
// howto 觉得了shutdown的行为
int shutdown(int sockfd, int howto);

close并非立即关闭一个连接,而是将引用计数减一,当fd的引用计数为0,才是正真的关闭连接,所以在多进程中要在父子进程中都要调用close

howto 可选值含义
SHUT_RD断开输入流。套接字无法接收数据(即使输入缓冲区收到数据也被抹去),无法调用输入相关函数
SHUT_WR断开输出流。套接字无法发送数据,但如果输出缓冲区中还有未传输的数据,则将传递到目标主机
SHUT_RDWR同时断开 I/O 流。相当于分两次调用 shutdown(),其中一次以 SHUT_RD 为参数,另一次以 SHUT_WR 为参数。
  • echo server
    `
``
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

int main()
{
	int sockfd = socket(AF_INET,SOCK_STREAM,0);
	assert( sockfd != -1 );

	struct sockaddr_in saddr,caddr;
	memset(&saddr,0,sizeof(saddr));
	saddr.sin_family = AF_INET;
	saddr.sin_port = htons(6500); 
	saddr.sin_addr.s_addr = inet_addr("127.0.0.1");

	int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
	assert( res != -1 );


	listen(sockfd,5);

	while( 1 )
	{
		int len = sizeof(caddr);
		printf("accept wait\n");
		int c = accept(sockfd,(struct sockaddr*)&caddr,&len);
		if ( c < 0 )
		{
			continue;
		}

		printf("ip=%s,port:%d\n",inet_ntoa(caddr.sin_addr),ntohs(caddr.sin_port));

        char buff[128] = {0};
		int n = recv(c,buff,127,0);
		if ( n == 0 )
		{
		    continue;
		}
		printf("buff=%s\n",buff);
		send(c,buff,sizeof(buff),0);

	}
}

 >echo服务足够的简单,但也存在一些问题,比如粘包、一对一连接等等。粘包的处理方式很统一,定义应用层包结构,和应用层buff就可以了。但如何解决一对一连接的方式却种类繁多,但一般新的迭代都是为了改进之前的缺点,伴随历史长河卡看看各个方式的应用场景。
 
 
 ==应对多客户端的网络应用==,最简单的解决方式是在服务器端使用多线程(或多进程)。多线程(或多进程)的目的是让每个连接都拥有独立的线程(或进程),这样任何一个连接的阻塞都不会影响其他的连接。具体使用多进程还是多线程,并 没有一个特定的模式。传统意义上,进程的开销要远远大于线程,所以,如果需要同时为较多的客户端提供服务,则不推 荐使用多进程;如果单个服务执行体需要消耗较多的CPU资源,譬如需要进行大规模或长时间的数据运算或文件访问,则进程较为安全。在Unix/Linux环境中,使用 pthread_create () 创建新线程,fork() 创建新进程。
 
 ## 多进程服务器
1. 单进程创建socket,并进行listen和accept,接收到连接后创建进程处理连接
2. 单进程创建socket,并进行listen,预先创建好多个工作进程accept()在同一个服务器套接字


```c
 master(server)                              master(server)  
    |                                              |
  socket(2)                                    socket(2)    
    |                                              |    
  bind(2)                                       bind(2)     
    |                                              |
  listen(2)                                     listen(2) 
    |                                             / | \ 
    |   <--------------                          /  |  \  
   accept(2)          |                         /   |   \
    |                 |                   fork(2) fork(2) fork(2)    
    |fork(2)          |                        |    |    | 
    |                 |                    worker worker worker   
  worker              | EventLoop              |    |    | <-----|
    |                 |                          accept(2)  EventLoop
 handle ------------->|                             |            |
                                                 handle -------->|

由于早期线程概念没有出现时,出现很多像这两种模型多进程服务器,所以在当时环境下操作系统提供很多像管道、共享内存、信号量以及socketpair等等的IPC方式。随着时代的发展和进程本身的特点,这种服务模型对于网络应用服务器无论从任务调度、系统资源利用率、数据通信等等都暴露出了性能问题。

  1. fork本身为系统调用,先抛开上文切换采用int80软中断陷入内核本身的开销,单看他需要拷贝父进程的资源这一操作就已经是很耗性能了,虽然后来有了copy-by-write,但我们还是不能忽略他对应服务器本身的影响
  2. 因为进程间数据是虚拟内存映射的特点,各个进程间内存是独立的,以32位系统为例一个进程启动操作系统会为它分配4G的虚拟内存,高3G为用户空间,低1G为内核空间。这就造成了同一服务之间的数据通信是个很麻烦的事情。
  3. 众所周知进程的颗粒度是很大,而作为网络应用服务器,被网络IO或者磁盘IO阻塞的概率是非常大,此时会被操作系统挂起来等待调度,所以后面为了提高系统利用率会有自旋锁的存在。
  4. 惊群效应,虽然socket可以设置在子进程中自动关闭,但一般进程池的模式下都会存在多个进程监听同一个事件,当然也可以有主线程accept然后用再传递描述符给子进程来避免进程池导致的惊群效应

多线程服务器

  1. 单线程创建socket,并进行listen和accept,接收到连接后创建线程处理连接
  2. 单线程创建socket,并进行listen,预先创建好多个工作线程accept()在同一个服务器套接字
 master(server)                              master(server)  
    |                                              |
  socket(2)                                    socket(2)    
    |                                              |    
  bind(2)                                       bind(2)     
    |                                              |
  listen(2)                                     listen(2) 
    |                                             / | \ 
    |   <--------------                          /  |  \  
  accept(2)           |                         /   |   \
    |                 |                    pthread_create(3|pthread_create(3)|                        |    |    | 
    |                 |                    worker worker worker   
  worker              | EventLoop              |    |    | <-----|
    |                 |                          accept(2)  EventLoop
 handle ------------->|                             |            |
                                                 handle -------->|

上述多线程的服务器模型似乎完美的解决了为多个客户端提供问答服务的要求,但其实并不尽然。如果要同时响应成百上千路的连接请求,则无论多线程还是多进程都会严重占据系统资源,降低系统对外界响应效率,而线程与进程本身也更容易进入假死状态。很多程序员可能会考虑使用“线程池”或“连接池”。“线程池”旨在减少创建和销毁线程的频率,其维持一定合理数量的线程,并让空闲的线程重新承担新的执行任务。“连接池”维持连接的缓存池,尽量重用已有的连接、减少创建和关闭连接的频率。这两种技术都可以很好的降低系统开销,都被广泛应用很多大型系统,如 websphere、tomcat 和各种数据库等。但是,“线程池”和“连接池”技术也只是在一定程度上缓解了频繁调用 IO 接口带来的资源占用。而且,所谓“池”始终有其上限,当请求大大超过上限时,“池”构成的系统对外界的响应并不比没有池的时候效果好多少。所以使用“池”必须考虑其面临的响应规模,并根据响应规模调整“池”的大小。 对应上例中的所面临的可能同时出现的上千甚至上万次的客户端请求,“线程池”或“连接池”或许可以缓解部分压力,但是不能解决所有问题。总之,多线程模型可以方便高效的解决小规模的服务请求,但面对大规模的服务请求,多线程模型并不是最佳方案

网络IO模型

socket默认是阻塞的,可以在创建时通过SOCK_NONBLOCK参数或者fcntl系统调用的F_SETFL命令设置为非阻塞,阻塞于非阻塞的概念适用于所有文件描述符不止是Socket。阻塞IO执行的系统调用可能会因为无法立即完成被操作系统挂起,直到事件发生为止。而非阻塞IO执行的系统调用总是立即返回,如果事件没有发生系统调用就返回-1,并设置全局errno。

显然我们只有在就绪事件已经产生的情况下操作非阻塞IO,才能提高应用程序的效率,因此非阻塞IO通常要和IO通知机制一起使用,比如IO复用和SIGIO信号。或者采用异步IO的方式,linux 下主要有两套异步 IO,分别是 glibc 实现版本,和 linux 内核实现、libaio 封装的版本

  • 阻塞IO(blocking IO):程序阻塞与读写函数
  • 非阻塞IO(non-blocking IO):程序不阻塞与读写函数,但需要频繁用户和内核空间切换
  • 多路复用IO(multiplexing IO):程序阻塞于IO复用系统调用,但可以监听多个IO事件 ,对IO本身的读写操作是非阻塞的
  • 信号驱动式IO(signal-driven IO):信号触发读写就绪事件,用户执行读写操作,程序没有阻塞阶段
  • 异步IO(asynchronous IO):内核执行读写操作并触犯读写完成事件,程序没有阻塞阶段

从理论上讲,前四种IO模型都是同步IO模型,因为IO的读写操作都是在IO就绪事件,由应用程序来完成的。而POSIX规范所定义的IO规范是 用户程序告诉内核IO执行读写操作的缓存区位置,由内核完成IO操作后通知应用程序,应用程序没有阻塞阶段。所以你可以认为同步IO是向应用程序通知就绪事件,异步IO是向应用程序通知就绪事件。并且这个5种IO模型都是单线程下的。

取快递例子

举个例子就是你取快递,当你不知道快递是否到达时候,你只有亲自跑过去问问,你过去发现没有到时候,如果在哪里一直等待就是阻塞IO。如果立即返回就是非阻塞IO,但你的快递还是没有拿到,所以你得不断的一次次跑过去问。这样你在做其他事情和取快递之前同时只能选择一个。而且你还不知道什么时候改选择哪个,只能不断的去试。 然而如果快递站预留了你的电话,那此时你就可以专心干其他的事情了,等就绪的快递电话打过来,你再中断手头的事情,去取快递。但是这样对于取快递和干其他事情的进度相对来说还是同步的,因为没发同时完成。此时如果你加钱让快递员把你的快递到了之后 送到你指定的家门口,这样当你干其他事情的同时,取快递的进度完全是异步完成的,你完全不用关心,只需要接受完成的事件就好。

这 5 种 I/O 模型的对比如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-itR09KGw-1602748340559)(https://raw.githubusercontent.com/panjf2000/illustrations/master/go/5-io-model.jpg?imageView2/2/w/1280/format/jpg/interlace/1/q/100)]

IO复用

linux上常用的IO复用是 select(2)、poll(2)、epoll(4)

  • select
#include <sys/select.h>
#include <sys/time.h>
// 返回值:就绪描述符的数目,超时返回0,出错返回-1
int select(int maxfdp1,fd_set *readset,fd_set *writeset,fd_set *exceptset,const struct timeval *timeout)
  • poll
# include <poll.h>
int poll ( struct pollfd * fds, unsigned int nfds, int timeout);

poll的机制与select类似,与select在本质上没有多大差别,管理多个描述符也是进行轮询,根据描述符的状态进行处理,但是poll没有最大文件描述符数量的限制。poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。

  • epoll
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。

ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

IO复用select(2)poll(2)epoll(4)
事件集合用户通过3个参数分别传入感兴趣的可读、可写及异常事件,内核通过对这些参数的修改来反馈就绪事件,
这样使得每次调用select都要设置3个参数。
统一处理所有事件类型,
通过pollfd.events传入感兴趣的事件,内核修改后来反馈就绪事件。
内核采用红黑树维护事件表,用户只需注册一次无需反复传入感兴趣的事件。
epoll_wait仅用来反馈就绪事件
应用程序索引就绪
文件描述符的时间复杂度
O(n)O(n)O(1)
最大支持文件描述符数有内核代码宏定义限制
linux早期版本为1024
由系统全局文件描述符大小cat/proc/sys/fs/file-max
或者ulimit -n 限制
由系统全局文件描述符大小cat/proc/sys/fs/file-max
或者ulimit -n 限制
工作模式只支持LT只支持LT支持ET
内核实现内核采用轮询的方式检测就绪事件内核采用轮询的方式检
测就绪事件
内核采用注册加回调的方式检
测就绪事件

两种高效的事件处理模式

  • Reactor 模

Reactor 模式本质上指的是使用 I/O 多路复用(I/O multiplexing) + 非阻塞 I/O(non-blocking I/O) 的模式。
通常设置一个主线程负责做 event-loop 事件循环和 I/O 读写,通过 select/poll/epoll_wait 等系统调用监听 I/O 事件,业务逻辑提交给其他工作线程去做。而所谓『非阻塞 I/O』的核心思想是指避免阻塞在 read() 或者 write() 或者其他的 I/O 系统调用上,这样可以最大限度的复用 event-loop 线程,让一个线程能服务于多个 sockets。在 Reactor 模式中,I/O 线程只能阻塞在 I/O multiplexing 函数上(select/poll/epoll_wait)。

使用同步I/O模型(以epoll_wait为例)实现的Reactor模式的多线程工作流程是:

  1. 主线程往epoll内核时间表中注册socket上的读就绪事件。
  2. 主线程调用epoll_wait等待socket上有数据可读。
  3. 当socket上有数据可读时,epoll_wait通知主线程。主线程则将socket可读事件放入请求队列。
  4. 睡眠在请求队列上的某个工作线程被唤醒,它从socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件。
  5. 主线程调用epoll_wait等待socket可写。
  6. 当socket可写时,epoll_wait通知
  7. 睡眠在请求队列上的某一个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果

image

accept 连接以及 conn 上的读写操作若是在主线程完成,则要求是非阻塞 I/O。因为 Reactor 模式一条最重要的原则就是:I/O 操作不能阻塞 event-loop 事件循环。实际上 event loop 可能也可以是多线程的,只是一个线程里只有一个 select/poll/epoll_wait。mulitple-Reactors的应用以及变种在实际应用中也是非常多的

  • Proactor模式

与Reactor模式不同,Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑

使用异步I/O模型(以aio_read和aio_write为例)实现的Proactor模式的工作流程是:

  1. 主线程调用aio_read函数向内核注册socket上的读完事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序(可以通过信号)
  2. 主线程继续处理其他逻辑。
  3. 当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,已通知应用程序数据已经可以用了。
  4. 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序
  5. 主线程继续处理其他逻辑。
  6. 当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号,已通知应用程序数据已经发送完毕。
  7. 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket。

image

常见并发服务器方案比较:

并发模型unp应对多进程多线程阻塞IOIO复用长连接并发性多核开销互通顺序性线程数量确定特点
accept + read/write0NNYNNNNYY一次服务一个客户端
accept + fork1YNYNYYNYNprocess-per-connection
accept + thread6NYYNYYYYNthread-per-connection
prefork2/3/4/5YNYNYYNYN见UNP
pre threaded7/8NYYNYYYYN见UNP
reactorsec6.8NNNYYNYYY单线程reactor
reactor + thread-per-taskNYNYYYYNNthread-per-request
reactor + worker threadNYNYYYYYNworker-thread-per-connection
reactor + thread pollNYNYYYYNY主线程IO,工作线程计算
reactors in threadsNYNYYYYYYone loop per thread
reactors in processesYNNYYYNYYnginx
reactors in thread pollNYNYYYYNY最灵活IO与cpu配置

思考讨论

有一台机器,它有一个 IP,上面运行了一个 TCP 服务程序,程序只侦听一个端口,问:从理论上讲(只考虑 TCP/IP 这一层面,不考虑IPv6)这个服务程序可以支持多少并发 TCP 连接?答 65536 上下的直接刷掉。

  • 理论

TCP 连接是虚拟的连接,不是电路连接,维持TCP连接理论上不占用网络资源(会占用两头程序的系统资源)。只要连接的双方认为 TCP 连接存在,并且可以互相发送 IP packet,那么TCP连接就一直存在。所以只要绕过传输层发送SYN、ACK、FIN等IP packet完成三次握手,双方维护好一个虚拟连接的状态就可以认为建立连接了,所以在只考虑 IPv4 的情况下,并发数的理论上限是客户端IP上限(2^32)* 端口上限(2^16)= (2^48)

  • 实际

在现在的 Linux 操作系统上,如果用 socket()/connect() 或 accept() 来创建 TCP 连接,那么每个连接至少要占用一个文件描述符(file descriptor)。为什么说“至少”?因为文件描述符可以复制,比如 dup();也可以被继承,比如 fork();这样可能出现系统里边同一个 TCP 连接有多个文件描述符与之对应。并且TCP/IP为了维护可靠性,也会有一些系统资源占用。所以实际上并发连接数首先于全局文件描述符大小和系统资源大小

  • 避免EMFILE

假设在non-blocking+IO复用中系统文件描述符被用光,accpet返回EMFILE错误。这意味着系统无法为新的连接创建socket文件描述符,我们就无法Close(2)这个连接,客户端认为连接正常,服务程序继续运行在EVENTLOOP再次调用epoll_wiat就会立即返回,因为新连接还在等待处理,此时我们的eventLoop就会变成busyLoop,CPU利用率接近100%,这即影响了同一eventLoop上的连接,也影响同一机器上的其他服务。

基于对以这个问题的分析,说明单独谈论“TCP并发连接数”是没有意义的,因为连接数基本上是要多少有多少。更有意义的性能指标或许是:“每秒钟收发多少条消息”、“每秒钟收发多少字节的数据”、“支持多少个活动的并发客户端”等等。并且我们应用程序要根据系统来硬性限制服务器并发连接数,避免出现EMFILE

Seastar

Seastar是一个事件驱动的框架,允许您以相对直接的方式编写非阻塞的异步代码。它的API基于 futures(c++11新特性)。 Seastar利用以下概念来实现卓越的性能:

  • Cooperative micro-task scheduler(合作的微任务调度器):每个核心运行一个合作任务调度器,而不是运行线程。每个任务通常都是非常轻量级的,只要处理最后一次I/O操作的结果并提交一个新的结果就可以运行。

  • Share-nothing SMP architecture(无共享SMP架构):每个内核独立于SMP系统中的其他的内核运行。内存,数据结构和CPU时间不共享;相反,核心间通信使用显示的消息传递。 Seastar core 通常被称为 shard。

  • Future based APIs(基于future的API):futures允许您提交I/O操作,并链接完成I/O操作时要执行的任务。这是很容易并行运行多个I/O操作。例如,在响应TCP连接请求时,可以发出多个磁盘I/O请求,或发送相同的系统上的消息给其他核,或者发送请求到集群中的其他节点,等待一些或全部结果完成,汇总结果并发送响应。

  • Share-nothing TCP stack(无共享TCP堆栈):虽然Seastar可以使用主机操作系统的TCP堆栈,但它还提供了自己的高性能TCP/IP堆栈,该堆栈构建在任务调度器和无共享架构之上。堆栈在两个方向上提供零拷贝:您可以直接从TCP堆栈的缓冲区处理数据,并将您自己的数据结构的内容作为消息的一部分发送,而不会产生副本。

  • DMA-based storage APIs(基于DMA的存储API):与网络堆栈一样,Seastar提供零拷贝存储API,允许您将数据存入存储设备。

Linux 最新SO_REUSEPORT特性

在Linux 3.9之前,只存在SO_REUSEADDR配置项:

1、改变了通配绑定时处理源地址冲突的处理方式,其具体的表现方式为:未设置SO_REUSEADDR时,socketA先绑定到0.0.0.0:21,后socketB绑定192.168.0.1:21将失败。但在设置SO_REUSEADDR后socketB将绑定成功。并且这个设置对于socketA(通配绑定)和socketB(特定绑定)的绑定是顺序无关的

2、改变了系统对处于TIME_WAIT状态的socket的看待方式:在未设置SO_REUSEADDR时,内核将一个处于TIME_WAIT状态的socketA仍然看成是一个绑定了指定ip和port的有效socket,因此此时如果另外一个socketB试图绑定相同的ip和port都将失败(不满足规则3),直到socketA被真正释放后,才能够绑定成功。如果socketB设置SO_REUSEADDR(仅仅只需要socketB进行设置),这种情况下socketB的绑定调用将成功返回,但真正生效需要在socketA被真正释放后。

下表总结了BSD在各个情况下的绑定情况:
SO_REUSEADDRsocketAsocketBResult
ON/OFF192.168.0.1:21192.168.0.1:21Error (EADDRINUSE)
ON/OFF192.168.0.1:2110.0.0.1:21OK
ON/OFF10.0.0.1:21192.168.0.1:21OK
OFF0.0.0.0:21192.168.1.0:21Error (EADDRINUSE)
OFF192.168.1.0:210.0.0.0:21Error (EADDRINUSE)
ON0.0.0.0:21192.168.1.0:21OK
ON192.168.1.0:210.0.0.0:21OK
ON/OF0.0.0.0:210.0.0.0:21Error (EADDRINUSE)

Linux 内核3.9加入了SO_REUSEPORT:

允许将多个socket绑定到相同的地址和端口,前提每个socket绑定前都需设置SO_REUSEPORT。如果第一个绑定的socket未设置SO_REUSEPORT,那么其他的socket无论有没有设置SO_REUSEPORT都无法绑定到该地址和端口直到第一个socket释放了绑定。这样每一个进程有一个独立的监听socket,并且bind相同的ip:port,独立的listen()和accept();提高接收连接的能力。(例如nginx多进程同时监听同一个ip:port)

解决的问题:

  1. 避免了应用层多线程或者进程监听同一ip:port的“惊群效应”。
  2. 内核层面实现负载均衡,保证每个进程或者线程接收均衡的连接数。
  3. 只有effective-user-id相同的服务器进程才能监听同一ip:port (安全性考虑)

SO_REUSEPORT支持多个进程或者线程绑定到同一端口,提高服务器程序的性能,解决的问题:

其核心的实现主要有三点:

  1. 扩展 socket option,增加 SO_REUSEPORT 选项,用来设置 reuseport。
  2. 修改 bind 系统调用实现,以便支持可以绑定到相同的 IP 和端口
  3. 修改处理新建连接的实现,查找 listener 的时候,能够支持在监听相同 IP 和端口的多个 sock 之间均衡选择。

有了SO_RESUEPORT后,每个进程可以自己创建socket、bind、listen、accept相同的地址和端口,各自是独立平等的。让多进程监听同一个端口,各个进程中accept socket fd不一样,有新连接建立时,内核只会唤醒一个进程来accept,并且保证唤醒的均衡性

gonet

Go 基于 I/O multiplexing 和 goroutine 构建了一个简洁而高性能的原生网络模型(基于 Go 的 I/O 多路复用 netpoll ),提供了 goroutine-per-connection 这样简单的网络编程模式。在这种模式下,开发者使用的是同步的模式去编写异步的逻辑,极大地降低了开发者编写网络应用时的心智负担,且借助于 Go runtime scheduler 对 goroutines 的高效调度,这个原生网络模型不论从适用性还是性能上都足以满足绝大部分的应用场景。

  • goroutine-per-connectionr
package main

import (
	"net"
)

func handleConnection(c net.Conn) {
	//读写数据
	buffer := make([]byte, 1024)
	c.Read(buffer)
	c.Write([]byte("Hello from server"))
}

func main() {
	l, err := net.Listen("tcp", "127.0.01:8888")
	if err != nil {
		return
	}
	defer l.Close()
	for {
		c, err := l.Accept()
		if err!= nil {
			return
		}
		go handleConnection(c)
	}
}

在这种模式下,开发者使用的是同步的模式去编写异步的逻辑而且对于开发者来说 I/O 是否阻塞是无感知的,也就是说开发者无需考虑 goroutines 甚至更底层的线程、进程的调度和上下文切换。而 Go netpoll 最底层的事件驱动技术肯定是基于 epoll/kqueue/iocp 这一类的 I/O 事件驱动技术,只不过是把这些调度和上下文切换的工作转移到了 runtime 的 Go scheduler,让它来负责调度 goroutines,从而极大地降低了程序员的心智负担!

  • gonet对于epoll的封装
#include <sys/epoll.h>  
int epoll_create(int size);  
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

// Go 对上面三个调用的封装
func netpollinit()
func netpollopen(fd uintptr, pd *pollDesc) int32
func netpoll(block bool) gList
  • Go netpoll 核心

Go netpoll 通过在底层对 epoll/kqueue/iocp 的封装,从而实现了使用同步编程模式达到异步执行的效果。总结来说,所有的网络操作都以网络描述符 netFD 为中心实现。netFD 与底层 PollDesc 结构绑定,当在一个 netFD 上读写遇到 EAGAIN 错误时,就将当前 goroutine 存储到这个 netFD 对应的 PollDesc 中,同时调用 gopark 把当前 goroutine 给 park 住,避免因IO阻塞而被操作系统被挂起。直到这个 netFD 上再次发生读写事件,才将此 goroutine 给 ready 激活重新运行。显然,在底层通知 goroutine 再次发生读写等事件的方式就是 epoll/kqueue/iocp 等事件驱动机制。

DPDK

  • Linux + x86网络IO瓶颈

1.传统的收发报文方式都必须采用硬中断来做通讯,每次硬中断大约消耗100微秒,这还不算因为终止上下文所带来的Cache Miss。

2.数据必须从内核态用户态之间切换拷贝带来大量CPU消耗,全局锁竞争。

3.收发包都有系统调用的开销。

4.内核工作在多核上,为可全局一致,即使采用Lock Free,也避免不了锁总线、内存屏障带来的性能损耗。

5.从网卡到业务进程,经过的路径太长,有些其实未必要的,例如netfilter框架,这些都带来一定的消耗,而且容易Cache Miss。

  • DPDK的基本原理

DPDK的基石UIO
为了让驱动运行在用户态,Linux提供UIO机制。使用UIO可以通过read感知中断,通过mmap实现和网卡的通讯。
UIO原理:
https://cloud.tencent.com/developer/article/1198333写自定义目录标题)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值