- file descriptor(fd)
- 磁盘IO的特殊性
- Socket API
- 客户端API
- 服务端API
- int socket(int af, int type, int protocol);
- ssize_t send(int sockfd, const void *buf, size_t len, int flags);
- ssize_t recv(int sockfd, void *buf, size_t len, int flags);
- int bind(int sock, struct sockaddr *addr, socklen_t addrlen);
- int listen(int sock, int backlog);
- int accept(int sock, struct sockaddr addr, socklen_t addrlen);
- readv 和 writev 函数
- 总结
- 主机字节序和网络字节序
- SO_REUSEADDR 和 SO_REUSEPORT
file descriptor(fd)
在 linux 系统中,使用 task_struct 管理一个进程,其中就有 files_struct 结构,该结构负责管理进程所拥有的文件描述符。
files_struct
struct files_struct {...struct fdtable *fdt;struct fdtable fdtab;...};
每个 files_struct 都含有 fdtable 结构,本质上是文件描述符的集合,因此返回的文件描述符是一个int类型,表示的就是这个集合的下标。
默认情况下:
- 0:stdin,标准输入
- 1:stdout,标准输出
- 2:stderr,标准错误
重定向
底层使用的是 dup() 和 dup2()方法,本质上是把某个文件描述符值赋值给 fdtable 其他下标对应的文件描述符
常见形式有:
- [n] < file:n不填时默认为0,即标准输入
- [n] > file:n不填时默认为1,即标准输出
echo “hello” > file1;
./test < file1;
磁盘IO的特殊性
磁盘IO不同于网络IO,磁盘IO只能是阻塞的,也就是并不能将其设置为O_NONBLOCK,对其read/write也不会返回EAGAIN。这是POSIX系统对磁盘文件(regular files)的底层设计,当监听对应的磁盘fd时,总是返回Ready状态,但实际读取时如果文件数据不在内存缓存中,则read操作本身还是会“阻塞”等待数据从磁盘读出。
由于这个原因,EPOLL不支持监听磁盘IO。
f = open('node.py')fd = f.fileno()while True:r, w, e = select.select([fd], [], []) # 使用select可以运行print '>', repr(os.read(fd, 10))time.sleep(1)self._impl.register(fd, events | self.ERROR) # 使用epoll将会报错# IOError: [Errno 1] Operation not permitted
Socket API
以BSD Socket为标准(伯克利套接字)
客户端API
int socket(int af, int type, int protocol);
- 参数1:af 为地址族,也就是IP地址的类型,有 AF_INET 和 AF_INET6
- 参数2:type 为套接字类型,常用的有 SOCK_STREAM 和 SOCK_DGRAM
- 参数3:protocol 为传输协议,常用的有 IPPROTO_TCP 和 IPPROTO_UDP,还有 0。当由前两种组合可以确定只有一种协议满足时,可以直接填0,比方说 int clientfd = socket(AF_INET, SOCK_STREAM, 0);
- 返回值:返回的是socket类型,在linux平台上,socket是int类型,而在windows平台上是 SOCKET 宏类型,本质上也是一个int。当返回值为-1时,表示申请socket套接字失败,否则为大于0的值。具体的错误信息可以由errno中获取。
int clientfd = socket(AF_INET, SOCK_STREAM, 0);if (clientfd == -1){std::cout << "create client socket error. " << std::endl;return -1;}
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 参数1:系统调用 socket() 函数返回的套接字
- 参数2:保存着目标服务器IP端口信息的结构体。一般是通过sockaddr_in(in表示的是ipv4, 常见的还有sockaddr_in6,sockaddr_un等)指针转换为sockaddr指针
- 参数3:addr变量的大小,可由sizeof计算。在这里该参数不是一个指针,但是在accept()函数中则需要的是一个指针类型。
- 返回值:当调用失败时返回-1,且错误信息可由errno中获取
- 在阻塞和非阻塞IO下调用方式有区别:
阻塞:connect对应的是TCP三次握手的发送SYN操作(CLOSE -> SYS_SEND),因此在阻塞模式下会等待服务端返回的SYN-ACK报文(SYS_SEND -> ESTABLISH),至少阻塞一个RTT时间。
非阻塞: 非阻塞模式下,调用会立即返回,需要在实际利用对应clientfd之间,借助select或者poll检测是否已经可写,并且在linux平台还需要额外检测判断该socket是否报错(在socket报错的情况下,select和poll也会返回可写状态,即返回1)
```c sockaddr_in serveraddr; serveraddr.sin_family = AF_INET; serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS); // 解析服务端ip地址 serveraddr.sin_port = htons(SERVER_PORT);sockaddr_in serveraddr;serveraddr.sin_family = AF_INET;serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS); // 解析服务端ip地址serveraddr.sin_port = htons(SERVER_PORT);if (connect(clientfd, (sockaddr*)&serveraddr, sizeof(serveraddr)) == -1){std::cout << "connect socket error." << std::endl;close(clientfd);return -1;}
while (true){ int ret = connect(clientfd, (sockaddr*)&serveraddr, sizeof(serveraddr)); if (ret == 0){ std::cout << “connect to server successfully.” << std::endl; break; } else if (ret == -1){ if (errno == EINTR){ std::cout << “connecting interruptted by signal, try again.” << std::endl; continue; } else if (errno == EINPROGRESS){ // 连接正在重试,不用循环继续调用,直接break,属于正常情况 std::cout << “auto try connect now, don’t need to try again.” << std::endl; break; } else{ close(clientfd); std::cout << “connect error.” << std::endl; return -1; } } }
fd_set writeset; FD_ZERO(&writeset); FD_SET(clientfd, &writeset); timeval tv; tv.tv_sec = 3; tv.tv_usec = 0; if (select(clientfd+1, NULL, &writeset, NULL, &tv) != 1){ std::cout << “[select] connect to server error.” << std::endl; close(clientfd); return -1; }
// 补充判断阶段:
int err;
socklen_t len = static_cast
if (err == 0){ std::cout << “connect to server successfully.” << std::endl; } else{ std::cout << “connect to server error.” << std::endl; close(clientfd); return -1; }
<a name="KOe6Y"></a>##### ssize_t send(int sockfd, const void *buf, size_t len, int flags);相比于`ssize_t write(int fd, const void* buf, size_t nbytes)`多了一个标志位,但是`write`可用于向管道写入数据- 参数1:套接字- 参数2:待发送的数据缓存- 参数3:指明buf的长度- 参数4:标志位,一般填0- 返回值:当处于阻塞模式下时,会阻塞到可以完整发送数据时才返回,如当对端的接收窗口过小时,会导致一直阻塞,如果返回的值不等于len,则代表发送失败;在非阻塞模式下,需要考虑成功发送一部分(0 < ret < len),-1 和 ==len 的情况。**当=0时,一般意味着连接断开,如果是主动发送0字节长度的数据,虽然返回值依然是0但是底层会过滤掉,不会发送给对端,因此我们一般使用是否等于len去判断,而不是直接判断0**- 在阻塞和非阻塞IO下调用方式有区别:<br />阻塞:在阻塞模式下,会一直阻塞到发送完预设的数据长度,除非发生异常<br />**本质上是两次阻塞:1.阻塞等待有足够的写入空间;2.阻塞进行数据的从用户态到内核态的copy**。 非阻塞:在非阻塞模式下,要考虑发送是否被中断(INTER),或者因窗口过小而暂时无法发送(EWOULDBLOCK、EAGAIN),且需要使用循环判断是否完整的发送完数据<br />**非阻塞模式下,只有copy时的一次阻塞**```cint ret = send(clientfd, SEND_DATA, strlen(SEND_DATA), 0); // 这里使用的是阻塞模式,当无法发送时,会阻塞在这里.if (ret != strlen(SEND_DATA)){std::cout << "send data error." << std::endl;break;} else{std::cout << "send data successfully, count = " << count << std::endl;}
while (true){if (SendData(clientfd, SEND_DATA, strlen(SEND_DATA, 0))){std::cout << "send data successfully " << std::endl;} else {std::cout << "send data error." << std::endl;break;}}close(clientfd);// 发送函数bool SendData(int fd, const char* buf, int buf_len, int flag = 0){int send_len = 0;int ret = 0;while (true){ret = send(fd, buf + send_len, buf_len - send_len, flag);if (ret == -1){if (errno == EINTR || errno == EWOULDBLOCK || errno == EAGAIN){std::cout << "TCP Window size is too small or interrupted by single" << std::endl;continue;} else{std::cout << "send data error." << std::endl;return false;}} else if (ret == 0){std::cout << "send data error." << std::endl;return false;}send_len += ret;if (send_len == buf_len){return true;} else {return false;}}}
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
相比于ssize_t read(int fd, void* buf, size_t nbyte)多了一个标志位,但是read函数可用于从管道套接字读取数据
- 参数1:套接字
- 参数2:接收缓冲区
- 参数3:接收缓冲区的大小
- 参数4:标志位,一般为0
- 返回值:在阻塞模式下,=0意味着连接断开,-1异常,>0接收到数据(注意不一定等于预设的len);非阻塞模式下,-1时需要考虑中断情况
- 在阻塞和非阻塞IO下调用方式有区别:
阻塞:
本质上是两次阻塞:1.阻塞等待有可读数据;2.阻塞进行数据的从内核态到用户态的copy 非阻塞:
只有copy时的一次阻塞 ```c char buf[32] = {0}; int ret = recv(clientfd, buf, 32, 0); // 当没有接收到数据时,会阻塞在这里
if (ret > 0){ std::cout << “recv successfully.” << std::endl; } else{ std::cout << “recv data error.” << std::endl; }
```cwhile (true){char recvbuf[32] = {0};int ret = recv(clientfd, recvbuf, 32, 0);if (ret > 0){std::cout << "recv successfully." << std::endl;} else if (ret == 0){std::cout << "peer close the socket." << std::endl;break;} else if (ret == -1){if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK){std::cout << "There is no data avaliable now or interrupted" << std::endl;} else{break;}}}close(clientfd);
服务端API
int socket(int af, int type, int protocol);
与客户端同
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
与客户端同
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
与客户端同
int bind(int sock, struct sockaddr *addr, socklen_t addrlen);
- 参数1:套接字
- 参数2:保存着目标服务器IP端口信息的结构体。一般是通过sockaddr_in指针转换为sockaddr指针
- 参数3:addr的大小
- 返回值:-1表示错误
// 初始化服务器地址sockaddr_in bindaddr; // C++中struct可以不加bindaddr.sin_family = AF_INET;// htonl (h: host; to: to; n: net; l: unsigned long) 意思是将计算机的内存顺序转换成网络字节顺序(也就是大端序\小端序调整)// htons 也是一样的作用// INADDR_ANY表示应用程序不关心bind绑定的ip,由底层自动选择一个合适的ip地址,适合在多网卡机器上选择ip,相当于0.0.0.0// 如果只是想在本机上访问,则绑定ip可以使用127.0.0.1// 局域网中的内部机器访问,则绑定机器的局域网ip// 公网访问则需要是0.0.0.0或者 INADDR_ANYbindaddr.sin_addr.s_addr = htonl(INADDR_ANY);// 服务端只需要bind一个固定的端口,而如果是客户端则不能,否则同台机器将会无法启动多个客户端,因此对于客户端来说不进行bind或者bind(0),由底层自动分配// 原因:tcp使用4元组进行区分,server虽然之后一个port,但是连接的client有不同的ip和port,因此可以构成不同的四元组.bindaddr.sin_port = htons(3000);if (bind(listenfd, (sockaddr*)&bindaddr, sizeof(bindaddr)) == -1){std::cout << "bind listen socket error." << std::endl;close(listenfd);return -1;}
int listen(int sock, int backlog);
- 参数1:套接字
- 参数2:backlong参数,一般使用SOMAXCONN参数
作用1:决定全连接队列的上限 = min(SOMAXCONN, backlog);
作用2:决定半连接队列的上限 = min(SOMAXCONN, backlog, tcp_max_syn_backlog) - 返回值:-1表示失败
// 如果设置为SOMAXCONN则是由系统决定(可能是几百),当请求队列满了客户端会接收到ECONNREFUSED错误(linux)/ WSAECONNREFUSED错误(windows)if (listen(listenfd, SOMAXCONN) == -1){std::cout << "listen error." << std::endl;close(listenfd);return -1;}
int accept(int sock, struct sockaddr addr, socklen_t addrlen);
- 参数1:套接字
- 参数2:保存着目标服务器IP端口信息的结构体。一般是通过sockaddr_in指针转换为sockaddr指针。注意由于上上个socket函数一般是bind()函数,也有sockaddr指针,但是这里要使用一个空的sockaddr结构体指针,而不是复用bind()函数的那个。
- 参数3:指向socklen_t的指针,本质就是一个int指针
- 返回值:-1表示错误
- 在阻塞和非阻塞IO下调用方式有区别:
阻塞:
非阻塞:sockaddr_in clientaddr;socklen_t clientaddrlen = sizeof(clientaddr);int clientfd = accept(listenfd, (sockaddr*)&clientaddr, &clientaddrlen);if (clientfd != -1){ // 如果后续不调用recv函数,那么服务端的接收窗口会一步步被塞满,而最终导致客户端的发送窗口也被塞满。std::cout << "accept a client connection. " << std::endl;}
while (true){sockaddr_in clientaddr;socklen_t clientaddrlen = sizeof(clientaddr);int clientfd = accept(listenfd, (sockaddr*)&clientaddr, &clientaddrlen);if (clientfd == -1){if (errno == EINTR || errno == EWOULDBLOCK || errno == EAGAIN){std::cout << "empty accept queue or interrupted by single" << std::endl;continue;} else{std::cout << "accept error." << std::endl;break; // 这里直接退出了,可能会导致会面的accept接收队列不被处理,需要谨慎。}} else {std::cout << "accept successfully." << std::endl;// 将该clientfd保存起来,以作后续操作}}
readv 和 writev 函数
- ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
- ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
- ssize_t preadv(int fd, const struct iovec *iov, int iovcnt, off_t offset);
- ssize_t pwritev(int fd, const struct iovec *iov, int iovcnt, off_t offset);
用以将多个缓冲区数据同时写入一个fd套接字,使用read/write则需要循环调用,造成额外的性能开销。
struct iovec {void *iov_base; // 数据起始地址size_t iov_len; // 数据要传输的字节数}
实例代码:
char *info1 = "time to sleep";char *info2 = "go to work";struct iovec iov[2];iov[0].iov_base = info1;iov[0].iov_len = strlen(info1);iov[1].iov_base = info2;iov[1].iov_len = strlen(info2);ssize_t nwritten = writev(fd, iov, 2); // 返回成功写入的字节数
总结
- 阻塞模式和非阻塞模式下,有影响的API有:connect, accpet, send, recv
- 阻塞模式和非阻塞模式的最大区别是,当非阻塞模式下读取或写入数据失效时,会返回响应的错误码,这在IO多路复用上有极大的应用。比方说,在epoll的LT模式下,某个fd有可读数据时会一直触发EPOLLIN事件信号。假设数据超过了一次读取预设的接收缓冲区,当使用阻塞的recv,我们需要等待每次的EPOLLIN事件才进行一次读取,否则就可能发生recv空数据,造成阻塞,显而易见这种做法效率不高。如果使用的是非阻塞的recv,就可以在一次EPOLLIN事件里面,循环读取完,直到触发EAGAIN或EWOULDBLOCK信号再退出,效率更高。
而且,根据 man select 中的描述,有可能出现提示有可读事件,但是由于校验和不通过等原因而丢弃数据的情况,因此需要使用非阻塞IO
主机字节序和网络字节序
主机字节序
以 0x10203040 存储到 4001 开始的内存位置为例:
- 大端序
4001 4002 4003 400410 20 30 40
- 小端序
4001 4002 4003 400440 30 20 10
网络字节序(TCP/IP规定的字节序)
规定为 大端序,因此当传入TCP时,需要进行字节序转换
uint32_t htonl(uint32_t netlong); // 将long类型转换为网络字节序uint16_t htons(uint16_t netshort); // 将short类型转换为网络字节序
判断本机字节序的方式:
使用一个2字节的十六进制数,如 unsigned short num = 0x1234,然后将其强行转换为1字节的char类型,会把高字节部分丢弃,如 char mode = (char)&num。
也就是如果是小端序,那么结果是34,大端序则为12
SO_REUSEADDR 和 SO_REUSEPORT
SO_REUSEADDR
默认情况下,一个 Socket 以五元组作为唯一标识:{<protocol>, <src addr>, <src port>, <dest addr>, <dest port>}。
对于源IP来说,0.0.0.0代表着绑定本机所有网卡地址,对应到C++代码中则为:(一般用在服务端)
sockaddr_in bindaddr;bindaddr.sin_family = AF_INET;bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);
对于源port来说,0代表的是绑定随机的某个端口,对应到C++代码中则为:(一般用在客户端)
int clientfd = socket(AF_INET, SOCK_STREAM, 0);
因此在没有 SO_RESUEADDR 配置下,0.0.0.0:21 和 192.168.0.1:21 将会绑定失败。
| SO_REUSEADDR | socketA | socketB | Result |
|---|---|---|---|
| ON/OFF | 192.168.0.1:21 | 192.168.0.1:21 | Error |
| ON/OFF | 192.168.0.1:21 | 10.0.0.1:21 | OK |
| ON/OFF | 10.0.0.1:21 | 192.168.0.1:21 | OK |
| OFF | 0.0.0.0:21 | 192.168.0.1:21 | Error |
| OFF | 192.168.0.1:21 | 0.0.0.0:21 | Error |
| ON | 0.0.0.0:21 | 192.168.0.1:21 | OK |
| ON | 192.168.0.1:21 | 0.0.0.0:21 | OK |
| ON/OFF | 0.0.0.0:21 | 0.0.0.0:21 | Error |
同时当 TCP 主动关闭时,会进行 TIME_WAIT 状态,正常情况下会使程序无法在一段时间(2MSL)内无法重用对应的IP和端口。开启 SO_REUSEADDR 后即可进行重用。
在 BSD 上使用 SO_REUSEADDR 参数不需要关心其他 socket 是否设置了相同的 SO_REUSEADDR 参数,只要一方有设置,则设置方便可生效。但是在 linux 上必须要所有 socket 都设置。
SO_REUSEPORT
SO_REUSEPORT 参数解决了 特定IP 和 特定Port 绑定的冲突问题,如 192.168.0.1:21 和 10.0.0.1:21 在 SO_REUSEPORT 参数下,绑定才能够成功。
注意的是,SO_REUSEPORT 参数必须在两条 socket 间同时设置,才能绑定成功,同时对于已经处于 TIME_WAIT 状态的 socket,绑定也会失败,因此一般同时使用 SO_REUSEADDR 和 SO_REUSEPORT 参数
// 复用地址和端口号// SO_REUSEADDR : 用以解决当服务器主动close之后处于TIME_WAIT状态时,重新启动时出现的地址被占用情况// SO_REUSEPORT : 用以允许多个socket绑定到相同的ip和port,底层会把接收到的数据负载均衡到绑定的多个socket上// 一般用以在多进程/多线程中提高负载。int on = 1;setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (char*)&on, sizeof(on));setsockopt(listenfd, SOL_SOCKET, SO_REUSEPORT, (char*)&on, sizeof(on));
同时,设置了 SO_REUSEPORT并且绑定了同一个端口的 socket 在内核会被分配到同一个 group中,当有 tcp 连接事件到达时,内核会对{src ip, src port, desc ip, desc port} 进行hash,然后指定group中的一个进程处理,相当于内核级别的负载均衡,避免了惊群现象。
connect 报错
当我们开启了 SO_REUSEADDR 和 SO_REUSEPORT 后,意味着允许同时有多个 socket 拥有相同的 <protocol>, <src addr>, <src port>,在未开启这两个参数时,bind() 阶段将会报错。
在开启之后,如果这多个 socket 尝试去 connect 相同的目标IP和端口,则会造成报错。
