file descriptor(fd)

在 linux 系统中,使用 task_struct 管理一个进程,其中就有 files_struct 结构,该结构负责管理进程所拥有的文件描述符。

files_struct

  1. struct files_struct {
  2. ...
  3. struct fdtable *fdt;
  4. struct fdtable fdtab;
  5. ...
  6. };

每个 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。

  1. f = open('node.py')
  2. fd = f.fileno()
  3. while True:
  4. r, w, e = select.select([fd], [], []) # 使用select可以运行
  5. print '>', repr(os.read(fd, 10))
  6. time.sleep(1)
  7. self._impl.register(fd, events | self.ERROR) # 使用epoll将会报错
  8. # 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中获取。
  1. int clientfd = socket(AF_INET, SOCK_STREAM, 0);
  2. if (clientfd == -1){
  3. std::cout << "create client socket error. " << std::endl;
  4. return -1;
  5. }

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)
    1. sockaddr_in serveraddr;
    2. serveraddr.sin_family = AF_INET;
    3. serveraddr.sin_addr.s_addr = inet_addr(SERVER_ADDRESS); // 解析服务端ip地址
    4. serveraddr.sin_port = htons(SERVER_PORT);
    5. if (connect(clientfd, (sockaddr*)&serveraddr, sizeof(serveraddr)) == -1){
    6. std::cout << "connect socket error." << std::endl;
    7. close(clientfd);
    8. return -1;
    9. }
    ```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);

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(sizeof(err)); if(::getsockopt(clientfd, SOL_SOCKET, SO_ERROR, &err, &len) < 0){ close(clientfd); return -1; }

if (err == 0){ std::cout << “connect to server successfully.” << std::endl; } else{ std::cout << “connect to server error.” << std::endl; close(clientfd); return -1; }

  1. <a name="KOe6Y"></a>
  2. ##### ssize_t send(int sockfd, const void *buf, size_t len, int flags);
  3. 相比于`ssize_t write(int fd, const void* buf, size_t nbytes)`多了一个标志位,但是`write`可用于向管道写入数据
  4. - 参数1:套接字
  5. - 参数2:待发送的数据缓存
  6. - 参数3:指明buf的长度
  7. - 参数4:标志位,一般填0
  8. - 返回值:当处于阻塞模式下时,会阻塞到可以完整发送数据时才返回,如当对端的接收窗口过小时,会导致一直阻塞,如果返回的值不等于len,则代表发送失败;在非阻塞模式下,需要考虑成功发送一部分(0 < ret < len),-1 和 ==len 的情况。**当=0时,一般意味着连接断开,如果是主动发送0字节长度的数据,虽然返回值依然是0但是底层会过滤掉,不会发送给对端,因此我们一般使用是否等于len去判断,而不是直接判断0**
  9. - 在阻塞和非阻塞IO下调用方式有区别:<br />阻塞:在阻塞模式下,会一直阻塞到发送完预设的数据长度,除非发生异常<br />**本质上是两次阻塞:1.阻塞等待有足够的写入空间;2.阻塞进行数据的从用户态到内核态的copy**。 非阻塞:在非阻塞模式下,要考虑发送是否被中断(INTER),或者因窗口过小而暂时无法发送(EWOULDBLOCK、EAGAIN),且需要使用循环判断是否完整的发送完数据<br />**非阻塞模式下,只有copy时的一次阻塞**
  10. ```c
  11. int ret = send(clientfd, SEND_DATA, strlen(SEND_DATA), 0); // 这里使用的是阻塞模式,当无法发送时,会阻塞在这里.
  12. if (ret != strlen(SEND_DATA)){
  13. std::cout << "send data error." << std::endl;
  14. break;
  15. } else{
  16. std::cout << "send data successfully, count = " << count << std::endl;
  17. }
  1. while (true){
  2. if (SendData(clientfd, SEND_DATA, strlen(SEND_DATA, 0))){
  3. std::cout << "send data successfully " << std::endl;
  4. } else {
  5. std::cout << "send data error." << std::endl;
  6. break;
  7. }
  8. }
  9. close(clientfd);
  10. // 发送函数
  11. bool SendData(int fd, const char* buf, int buf_len, int flag = 0){
  12. int send_len = 0;
  13. int ret = 0;
  14. while (true){
  15. ret = send(fd, buf + send_len, buf_len - send_len, flag);
  16. if (ret == -1){
  17. if (errno == EINTR || errno == EWOULDBLOCK || errno == EAGAIN){
  18. std::cout << "TCP Window size is too small or interrupted by single" << std::endl;
  19. continue;
  20. } else{
  21. std::cout << "send data error." << std::endl;
  22. return false;
  23. }
  24. } else if (ret == 0){
  25. std::cout << "send data error." << std::endl;
  26. return false;
  27. }
  28. send_len += ret;
  29. if (send_len == buf_len){
  30. return true;
  31. } else {
  32. return false;
  33. }
  34. }
  35. }

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; }

  1. ```c
  2. while (true){
  3. char recvbuf[32] = {0};
  4. int ret = recv(clientfd, recvbuf, 32, 0);
  5. if (ret > 0){
  6. std::cout << "recv successfully." << std::endl;
  7. } else if (ret == 0){
  8. std::cout << "peer close the socket." << std::endl;
  9. break;
  10. } else if (ret == -1){
  11. if (errno == EINTR || errno == EAGAIN || errno == EWOULDBLOCK){
  12. std::cout << "There is no data avaliable now or interrupted" << std::endl;
  13. } else{
  14. break;
  15. }
  16. }
  17. }
  18. 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表示错误
  1. // 初始化服务器地址
  2. sockaddr_in bindaddr; // C++中struct可以不加
  3. bindaddr.sin_family = AF_INET;
  4. // htonl (h: host; to: to; n: net; l: unsigned long) 意思是将计算机的内存顺序转换成网络字节顺序(也就是大端序\小端序调整)
  5. // htons 也是一样的作用
  6. // INADDR_ANY表示应用程序不关心bind绑定的ip,由底层自动选择一个合适的ip地址,适合在多网卡机器上选择ip,相当于0.0.0.0
  7. // 如果只是想在本机上访问,则绑定ip可以使用127.0.0.1
  8. // 局域网中的内部机器访问,则绑定机器的局域网ip
  9. // 公网访问则需要是0.0.0.0或者 INADDR_ANY
  10. bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);
  11. // 服务端只需要bind一个固定的端口,而如果是客户端则不能,否则同台机器将会无法启动多个客户端,因此对于客户端来说不进行bind或者bind(0),由底层自动分配
  12. // 原因:tcp使用4元组进行区分,server虽然之后一个port,但是连接的client有不同的ip和port,因此可以构成不同的四元组.
  13. bindaddr.sin_port = htons(3000);
  14. if (bind(listenfd, (sockaddr*)&bindaddr, sizeof(bindaddr)) == -1){
  15. std::cout << "bind listen socket error." << std::endl;
  16. close(listenfd);
  17. return -1;
  18. }

int listen(int sock, int backlog);
  • 参数1:套接字
  • 参数2:backlong参数,一般使用SOMAXCONN参数
    作用1:决定全连接队列的上限 = min(SOMAXCONN, backlog);
    作用2:决定半连接队列的上限 = min(SOMAXCONN, backlog, tcp_max_syn_backlog)
  • 返回值:-1表示失败
  1. // 如果设置为SOMAXCONN则是由系统决定(可能是几百),当请求队列满了客户端会接收到ECONNREFUSED错误(linux)/ WSAECONNREFUSED错误(windows)
  2. if (listen(listenfd, SOMAXCONN) == -1){
  3. std::cout << "listen error." << std::endl;
  4. close(listenfd);
  5. return -1;
  6. }

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下调用方式有区别:
    阻塞:
    1. sockaddr_in clientaddr;
    2. socklen_t clientaddrlen = sizeof(clientaddr);
    3. int clientfd = accept(listenfd, (sockaddr*)&clientaddr, &clientaddrlen);
    4. if (clientfd != -1){ // 如果后续不调用recv函数,那么服务端的接收窗口会一步步被塞满,而最终导致客户端的发送窗口也被塞满。
    5. std::cout << "accept a client connection. " << std::endl;
    6. }
    非阻塞:
    1. while (true){
    2. sockaddr_in clientaddr;
    3. socklen_t clientaddrlen = sizeof(clientaddr);
    4. int clientfd = accept(listenfd, (sockaddr*)&clientaddr, &clientaddrlen);
    5. if (clientfd == -1){
    6. if (errno == EINTR || errno == EWOULDBLOCK || errno == EAGAIN){
    7. std::cout << "empty accept queue or interrupted by single" << std::endl;
    8. continue;
    9. } else{
    10. std::cout << "accept error." << std::endl;
    11. break; // 这里直接退出了,可能会导致会面的accept接收队列不被处理,需要谨慎。
    12. }
    13. } else {
    14. std::cout << "accept successfully." << std::endl;
    15. // 将该clientfd保存起来,以作后续操作
    16. }
    17. }

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则需要循环调用,造成额外的性能开销。

  1. struct iovec {
  2. void *iov_base; // 数据起始地址
  3. size_t iov_len; // 数据要传输的字节数
  4. }

实例代码:

  1. char *info1 = "time to sleep";
  2. char *info2 = "go to work";
  3. struct iovec iov[2];
  4. iov[0].iov_base = info1;
  5. iov[0].iov_len = strlen(info1);
  6. iov[1].iov_base = info2;
  7. iov[1].iov_len = strlen(info2);
  8. 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 开始的内存位置为例:

  • 大端序
  1. 4001 4002 4003 4004
  2. 10 20 30 40
  • 小端序
    1. 4001 4002 4003 4004
    2. 40 30 20 10

网络字节序(TCP/IP规定的字节序)

规定为 大端序,因此当传入TCP时,需要进行字节序转换

  1. uint32_t htonl(uint32_t netlong); // 将long类型转换为网络字节序
  2. 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++代码中则为:(一般用在服务端)

  1. sockaddr_in bindaddr;
  2. bindaddr.sin_family = AF_INET;
  3. bindaddr.sin_addr.s_addr = htonl(INADDR_ANY);

对于源port来说,0代表的是绑定随机的某个端口,对应到C++代码中则为:(一般用在客户端)

  1. int clientfd = socket(AF_INET, SOCK_STREAM, 0);

因此在没有 SO_RESUEADDR 配置下,0.0.0.0:21192.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:2110.0.0.1:21SO_REUSEPORT 参数下,绑定才能够成功。
注意的是,SO_REUSEPORT 参数必须在两条 socket 间同时设置,才能绑定成功,同时对于已经处于 TIME_WAIT 状态的 socket,绑定也会失败,因此一般同时使用 SO_REUSEADDRSO_REUSEPORT 参数

  1. // 复用地址和端口号
  2. // SO_REUSEADDR : 用以解决当服务器主动close之后处于TIME_WAIT状态时,重新启动时出现的地址被占用情况
  3. // SO_REUSEPORT : 用以允许多个socket绑定到相同的ip和port,底层会把接收到的数据负载均衡到绑定的多个socket上
  4. // 一般用以在多进程/多线程中提高负载。
  5. int on = 1;
  6. setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (char*)&on, sizeof(on));
  7. 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_REUSEADDRSO_REUSEPORT 后,意味着允许同时有多个 socket 拥有相同的 <protocol>, <src addr>, <src port>,在未开启这两个参数时,bind() 阶段将会报错。
在开启之后,如果这多个 socket 尝试去 connect 相同的目标IP和端口,则会造成报错。