一 概述
对于socket网络编程刚入门的同学而言,初次看到阻塞IO,非阻塞IO,同步IO模型,异步IO模型,IO复用,Reactor/Proactor模型,select/poll/epoll这些概念,往往一脸懵逼,似懂非懂,最后啃完各种教程往往都自我感觉已经懂了,但是自己实际去编写一个高性能服务器代码时候,却不知道从何入手。以前看懂的,过段时间又忘记了。这些都是因为没有亲手实践过这些概念,网上的代码示例大多数都是新手写的,过于简单,千篇一律,更重要的是不成体系。想通过阅读nginx,redis等成熟开源代码学习,但动不动大几万规模的代码都不是随便啃得下来的,这给网络编程的学习带来了不少难度。
笔者曾经在这个阶段吃了不少亏,饶了不少弯路。
这里笔者想通过一个系列系统的简洁教程让新手快速牢固掌握高并发服务器的编程模型精髓,从代码实用角度,从最简单的但客户服务器代码开始,到IO复用的epoll编程模型,一行一行代码渐进式解开socket网络服务器的神秘面纱。
这里用linux系统,c++语言来进行演示,windows系统的API也是大同小异,基本可以举一反三。
现在多数教程多数搬运来自《unix网络编程》,里面的五种io模型分类:阻塞IO模型,非阻塞IO模型,IO复用模型,信号驱动模型,异步IO模型。
这种分法本身就是迷惑性的,概念容易混淆。阻塞IO和非阻塞IO强调的是socket API的结果返回形式。而IO复用模型强调主线程的共享。前者和后者的IO不是同一个概念。不知道这个错误是由于翻译造成的,还是历史其他原因,这本身造成了很大迷惑性。
笔者把所有IO都归为两类:阻塞IO,非阻塞IO。
IO复用模型既可以用阻塞IO实现,也可以用非阻塞IO实现,IO复用强调的是一种编程模型,强调的是线程数量的使用。select/poll/epoll都是IO复用的类型的API,之所以要搞出这些花里胡哨的不同,是由于历史发展造成的,从select进化到epoll犹如绿皮火车进化到如今的磁悬浮列车。
Reactor和Proactor模型强调的是消息的类型,Reactor模型的消息是通知用户:“有数据了,自己派车过来自己装!”。Proactor模型的消息是通知用户:“有数据了,数据已经装好,拉走吧!”。
二 socket api参数
2.1 socket
#include <sys/socket.h>
int socket(int domain , int type , int protocol );
-domain:
Name
Purpose
AF_UNIX
本地通信
AF_INET
IPv4 Internet protocols
AF_INET6
IPv6 Internet protocols
-type
Name
Purpose
SOCK_STREAM
流式协议 ,默认TCP
SOCK_DGRAM
数据报协议,默认UDP
SOCK_RAW
提供原始网络协议访问
SOCK_PACKET
已过时且不应在新程序中使用
从 Linux 2.6.27 开始,type
参数有第二个目的:设置阻塞或者非阻塞。
设置linux socket为
非阻塞IO
模式的三种方法:
linux:
1,int s = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP);
2,fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFL, 0) | O_NONBLOCK);
3,ioctl(sockfd, FIONBIO, 1); //1:非阻塞 0:阻塞
-protocol
Name
Purpose
0
自动选择type类型的默认协议
SOCK_PACKET
用于接收来自设备驱动的原始数据包。直接从设备驱动接收原始数据包
-返回值
值
含义
正整数
成功时,返回新套接字的文件描述符
-1
错误,返回-1,设置errno表示错误类型
更多详细信息参考:https://man7.org/linux/man-pages/man2/socket.2.html
2.2 bind
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
-sockfd
socket套接字文件描述符.
-addr
socket绑定的地址,根据domain
的类型选择相应的结构体。
如果是IPv4,选择用sockaddr_in
,声明如下:
struct sockaddr_in {
sa_family_t sin_family; /* address family: AF_INET */
in_port_t sin_port; /* port in network byte order */
struct in_addr sin_addr; /* internet address */
};
/* Internet address */
struct in_addr {
uint32_t s_addr; /* address in network byte order */
};
如果是IPv6,选择用sockaddr_in6
,声明如下:
struct sockaddr_in6 {
sa_family_t sin6_family; /* AF_INET6 */
in_port_t sin6_port; /* port number */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* Scope ID (new in 2.4) */
};
struct in6_addr {
unsigned char s6_addr[16]; /* IPv6 address */
};
如果是linux本地通信,选择用sockaddr_un
,声明如下:
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[108]; /* Pathname */
};
-addrlen
addr的字节长度。
2.3 listen
#include <sys/socket.h>
int listen(int sockfd, int backlog);
-sockfd
sockfd 参数是一个引用套接字的文件描述符,SOCK_STREAM 或 SOCK_SEQPACKET 类型。
-backlog
backlog 参数定义了最大长度,sockfd 的挂起连接队列可能会增加。如果一个.当队列已满时,连接请求到达,客户端可能收到带有 ECONNREFUSED
指示的错误,或者,如果底层协议支持重传,请求可能是忽略,以便稍后重新尝试连接成功。
-返回值
成功返回0。出错返回-1,并且设置errno
错误类型。
2.4 accept
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict addrlen);
-sockfd
参数sockfd是一个使用socket()创建的套接字,使用bind()绑定到本地地址,并且是在listen()之后监听连接。
-addr
参数addr是指向sockaddr结构的指针。addr为NULL时,不填任何内容;在这种情况下,addrlen是未使用,也应为 NULL。
-addrlen
addr的字节长度。
-返回值
如果socket为默认阻塞IO
模式,有新客户端连接,返回一个文件描述符(非负整数)。如果没有新连接,会一直阻塞主进程。如果返回-1,连接出错。
如果socket为非阻塞IO
模式,有新客户端连接,返回一个文件描述符(非负整数)。没有新连接,直接返回-1,不会阻塞进程,并且设置errno
错误类型为EAGAIN
或EWOULDBLOCK
。虽然返回-1,并不是真错误,需要循环accept()直到返回文件描述符。如果errno
错误类型不是EAGAIN
或EWOULDBLOCK
2.5 send
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
系统调用 send()、sendto() 和 sendmsg() 用于将消息传输到另一个套接字。
-sockfd
参数sockfd是一个使用socket()创建的套接字
-buf
发送缓存的首地址。
-len
发送缓存的长度。
-flags
默认写0,详情参看 https://man7.org/linux/man-pages/man2/send.2.html
-返回值
如果socket为阻塞IO
模式,当消息大于套接字的发送缓冲区时,send()会阻塞;其他错误情况返回-1.
如果socket为非阻塞IO
模式,当消息大于套接字的发送缓冲区时,返回-1,且errno
被设置为EAGAIN
或 EWOULDBLOCK
。其他错误情况也返回-1,error
被设置为相应的错误码。
BUG
Linux 可能会返回错误信号 EPIPE
而不是 errno
的错误类型ENOTCONN
。
send() 调用只能在套接字位于连接状态(以便知道预期的接收者)。这send() 和 write() 之间的唯一区别是存在
flags
。使用零标志参数,send() 等效于write()。此外,以下调用:
send(sockfd, buf, len, flags);
等同于:
sendto(sockfd, buf, len, flags, NULL, 0);
如果 sendto() 用于连接模式(SOCK_STREAM,SOCK_SEQPACKET) 套接字,参数 dest_addr 和 addrlen 是被忽略(并且错误 EISCONN 可能会在它们不被返回时返回NULL 和 0),并且套接字时返回错误 ENOTCONN实际上并没有连接。否则,目标地址由 dest_addr 给出,addrlen 指定其大小。对于sendmsg(),目标的地址由msg.msg_name给出,用 msg.msg_namelen 指定它的大小。
2.6 recv
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
-sockfd
参数sockfd是一个使用socket()创建的套接字
-buf
接收缓存的首地址。
-len
接收缓存的长度。
-flags
默认写0,详情参看 https://man7.org/linux/man-pages/man2/send.2.html
-返回值
如果socket为阻塞IO
模式,如果套接字上没有可用的消息,recv()阻塞当前进程或线程,直到有可用消息。其他错误情况返回-1.
如果socket为非阻塞IO
模式,如果套接字上没有可用的消息,在这种情况下返回-1, 并且 errno设置为 EAGAIN 或 EWOULDBLOCK。其他错误情况也返回-1,error被设置为相应的错误码。
recv() 和 read() 之间的唯一区别是存在flags。设置flags为0,recv()通常等效于read()。此外,以下调用
recv(sockfd, buf, len, flags);
等同于:
recvfrom(sockfd, buf, len, flags, NULL, NULL);
所有三个调用都在成功时返回消息的长度完成。如果消息太长而无法放入提供的缓冲区,可能会丢弃多余的字节,具体取决于接收消息的套接字。
三 服务器模型
-
远古socket通信=单线程+阻塞IO
-
上古TCP服务器=主线程+多子线程+阻塞IO
-
中古TCP服务器=主线程+单子线程+阻塞IO
-
近古TCP服务器=主线程+单子线程+非阻塞IO
-
现代TCP服务器1=主线程+IO复用(select)+阻塞IO
-
现代TCP服务器2=主线程+IO复用(select)+非阻塞IO
-
现代TCP服务器3=主线程+IO复用(poll)+阻塞IO
-
当代TCP服务器1=主线程+IO复用(epoll LT)+阻塞IO
-
当代TCP服务器2=主线程+IO复用(epoll ET)+非阻塞IO
后面系列教程围绕以上服务器模型列表,一个一个分析,一行一行代码带领大家彻底搞懂高并发TCP服务器的核心。