SOCKET与NIO

1. 套接字(SOCKET)相关概念

网络套接字的基本操作:创建(socket)、命名(bind)、侦听(listen)、连接(accept)、关闭(shutdown)、发送(send)、接受(recv),以上这几种操作均是系统调用。

socket读文件过程 (1).png

服务端

服务端通过socket()函数定义一个socket文件描述符,并使用bind()&listen()函数监听指定端口,此时服务端状态CLOSED->LISTEN。接下来调用accept()函数监听已经完成TCP三次握手客户端队列,这里服务端的socket如果设置成阻塞(默认阻塞),当调用accept函数时,会阻塞当前进程,直到客户端队列中出现已经完成三次握手的客户端连接;同理如果服务端socket设置非阻塞,不管有没有准备好,将会立马返回结果。

调用完accept()函数将会生成一个新socket会客户端通信(new-socket),这个new-socket经历了三次握手,状态:CLOSED->SYNC_RCVD->ESTABLISHED
new-socket调用read()函数时,同样有阻塞和非阻塞两种模式,阻塞时当前进程(线程)一直等待直到网卡返回数据,非阻塞时立马返回结果。读取完数据且进行完逻辑处理后调用write()函数,将响应返回给客户端,虽然write也有NIO的模式,我们通常认为write时网卡不会阻塞,会立马返回。

通信完双方通过四次挥手,结束通信,服务端new-socket状态:ESTABLISHED-> CLOSEWAIT -> LAST ACK->CLOSED

注意:当服务端的socket在参与三次握手后,它会创建一个新socket参与通信,当然双方结束通信后只有新创建的socket会关闭,负责监听的socket还是listen状态。

客户端

客户端创建完socket后,通过调用connect()与服务端进行三次握手,握手完毕,客户端的状态CLOSED-> SYN_SEND -> ESTABLISHED
调用write()方法发送请求,同样write()几乎不会阻塞;然后再次调用read()方法阻塞(非阻塞)读取服务端响应。
最后通信完毕,客户端主动发起结束通信,状态:ESTABLISHED -> FIN WAIT1 -> FIN WAIT2 -> TIME WAIT

相关流程

三次握手与四次挥手.jpg

2. C语言中的SOCKET与NIO

c语言最纯粹,最接近底层的系统调用,可以不留余地的欣赏完真实socket的每个细节。

socket编程的函数

  1. 创建socket
/**
 * domain  指定发送通信的域(网络层)
    AF_UNIX:本地主机通信,与IPC类似; 
    AF_INET:Internet地址IPV4协议簇
    AF_IPX:  IPX/SPX 协议簇
    AF_APPLETALK: Apple Talk协议簇
    AF_NETBIOS NetBIOS 协议簇
    AF_INET6 Internet地址IPV6协议簇
    AF_IRDA Irda协议簇
    AF_BTH  蓝牙协议簇
 * type 指定通信类型(传输层)
    SOCK_STREAM:流套接字(eg: TCP)
    SOCK_DGRAM:数据报套接字 (eg: UDP)
    SOCK_RAW:原始套接字,可以处理ICMP、IGMP等上一层(网络层)报文
    SOCK_SEQPACKET:可提供基于数据报的伪流
 * protocol 协议
    IPPROTO_ICMP:ICMP协议,仅当 domain为AF_INET或AF_INET6,且type为SOCK_RAW时可选。
    IPPROTO_IGMP:IGMP协议,仅当 domain为AF_INET或AF_INET6,且type为SOCK_RAW时可选。
    BTHPROTO_RFCOMM:蓝牙协议,仅当 domain为AF_BTH,且type为SOCK_STREAM时可选。
    IPPROTO_TCP:TCP协议,仅当 domain为AF_INET或AF_INET6,且type为SOCK_STREAM时可选。
    IPPROTO_UDP:UDP协议,仅当 domain为AF_INET或AF_INET6,且type为SOCK_STREAM时可选。
    IPPROTO_ICMPv6:ICMPv6协议,仅当 domain为AF_INET或AF_INET6,且type为SOCK_RAW时可选。
 * return: socketfd(正常) / -1 (失败)
 */    
int socket(int domain, int type, int protocol)
    
  1. 命名bind
/**
 * sockfd:套接字描述符(socket句柄)
 * addr: 指向通用套接字的协议地址结构,包括协议、地址和端口等信息
 * addrlen: 协议地址结构的长度
 * return: 0 成功; -1 失败
 */
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
  1. 监听listen
/**
 * sockfd: socket句柄
 * backlog:sockfd接收连接的最大数目
 * return: 0 成功; -1 失败
 */
int listen(int sockfd, int backlog);
  1. 连接accept
/**
 * sockfd: socket句柄
 * addr: addr指向通用套接字的协议地址结构,包括协议、地址和端口等信息
 * addrlen: 协议地址结构的长度,一般为sizeof(sockaddr_in)
 * return: 创造返回一个新的socket与客户进程通信,原sockfd仍用于套接字侦听
 */
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  1. 接收recv
/**
 * sockfd:与远程通信连接的套接字描述符
 * buf:接收数据的缓冲区地址
 * len:缓冲区长度
 * flags:接收标志
 */
int recv(int sockfd, void *buf, size_t len, int flags);
  1. 读取read
/**
 * fd:套接字文件描述符
 * buf:要接收的字符数组
 * nbyte:最大读取的字节
 */
int read (int __fd, void *__buf, size_t __nbyte);
  1. 写入write
/**
 * fildes:套接字文件描述符
 * buf:要接收的字符数组
 * nbyte:写入字节
 */
int write(int fildes, const void *buf, int nbyte);

BIO模型与例子

我们接下来写一个简单的服务端接收客户端请求并相应的程序,包含了server socket整个的生命周期:

#include 
#include 
#include 
#include 

void bioServer();

int main() {
    bioServer();
    return 0;
}


void bioServer() {
    int serverFd, newClientFd;
    //创建一个internet ipv4协议簇的TCP流协议的文件描述符serverFd
    if ((serverFd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP)) == 0) {
        printf("create socket fail");
        return;
    }

    struct sockaddr_in serverAddress;
    memset(&serverAddress, 0, sizeof(serverAddress));
    int serverAddressLen = sizeof(serverAddress);
    serverAddress.sin_family = AF_INET;
    serverAddress.sin_port = htons(8088);
    serverAddress.sin_addr.s_addr = inet_addr("127.0.0.1");
    //将serverFd绑定到本地8088端口上
    if (bind(serverFd, (struct sockaddr *) &serverAddress, serverAddressLen) < 0) {
        printf("bind port fail");
        return;
    }
    //开始监听serverFd
    if (listen(serverFd, 3) < 0) {
        printf("listen fail");
        return;
    }
    //阻塞等待, 接收一个client请求,并生成新的文件描述符clientFd
    if ((newClientFd = accept(serverFd, (struct sockaddr *) &serverAddress, (socklen_t *) &serverAddressLen)) < 0) {
        printf("accept fail");
        return;
    }

    char buffer[1024] = {0};
    //read数据(阻塞读)
    read(newClientFd, buffer, 1024);
    printf("%s\n", buffer);
    char resp[] = "HTTP/1.1 200\nContent-Type: text/plain\n\nOK";
    //send数据
    write(newClientFd, resp, strlen(resp));
    close(newClientFd);
    close(serverFd);
    return;
}

我们可以通过curl或者telnet来测试以上程序是运行正常的,如果有多个客户端,那么我们不能仅仅用以上代码来处理一个客户端请求后就cloise,所以每当收到accept请求后,新建一个线程/进程去处理这个socket

#include
#include
#include
#include
#include
#include 

void bioServerLoop();

int main() {
    bioServerLoop();
    return 0;
}

void bioServerLoop() {
    //new socket
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd == -1) {
        exit(-1);
    }

    struct sockaddr_in servaddrddd, childAddr;
    int len, cfd;
    char buff[1024];
    memset(&servaddrddd, 0, sizeof(servaddrddd));
    servaddrddd.sin_family = AF_INET;
    servaddrddd.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddrddd.sin_port = htons(6666);

    //bind操作
    if (bind(fd, (const struct sockaddr *) &servaddrddd, sizeof(servaddrddd)) == -1) {
        exit(-1);
    }
    //listen操作
    if (listen(fd, 10) == -1) {
        exit(-1);
    }

    //这里循环去accept
    for (;;) {
        printf("wait for connect\n");
        len = sizeof(childAddr);
        //使用accept(阻塞)去获取客户端的新连接
        cfd = accept(fd, (struct sockaddr *) &childAddr, &len);
        if (cfd == -1) {
            break;
        }
        //fork函数, 复制本进程生成新的子进程
        if (fork() == 0) {
            //close(fd), 子进程不需要再有父进程的fd引用
            close(fd);
            //read数据(阻塞)
            int i = recv(cfd, buff, 1024, MSG_WAITALL);
            printf("recv msg from client: %s\n", buff);
            write(cfd, buff, i);
            //
            close(cfd);
        }
        //新客户端链接已经在子进程中处理,父进程不需要持有子进程的cfd引用
        close(cfd);
    }
}

BIO模型:

image.png

BIO程序到此结束,BIO的阻塞进程/线程的特点已经在程序标出:在acceptread的时候会阻塞。每当有一个客户端来连接,就要有一个线程/进程去阻塞,线程/进程对于操作系统来说是十分有限的,所以当客户端并发上到十万、百万级别的时候会迅速消耗完系统的资源。

NIO模型与例子

接下来我们讨论NIO,也就是非阻塞,引入非阻塞的目的就是解决阻塞操作过程中,避免创建大量线程去等待各自的IO操作;因为引入了非阻塞,完全可以使用一个线程去处理多个阻塞IO,这样线程的利用率就大大提升。

对于client socketconnect、read、write)和server socketaccept、read、write)来说,只要将其文件描述符设置成no_blocked,那么它的IO操作函数就可以不必等待,直接返回结果(可能有数据,也可能没有数据)。

客户端一般不涉及到大并发操作(其实是和其他io函数一样的),所以我们只讨论server socketNIO操作:acceptreadwrite,常用场景中write操作和网卡相关,一般不会阻塞,为了简化逻辑,我们先拿acceptread两个函数举例。

对于read操作来说,其实把网卡的数据拷贝到进程内存上速度是非常之快的,真正时间瓶颈是花在等待网卡把数据准备好的过程上,也就是上述说的IO等待的过程(accept操作是等待TCP连接建立,也是等待IO的过程)。

Read阻塞读
Accept阻塞读

c语言socket编程中,我们可以使用fcntl函数将某个文件描述符设置为非阻塞:

#include
#include
#include
#include
#include
#include
#include
#include 


void nioServer();

void nioServer() {
    //new socket
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    if (fd == -1) {
        exit(-1);
    }

    struct sockaddr_in servaddrddd, childAddr;
    int len, cfd;
    char buff[1024];
    memset(&servaddrddd, 0, sizeof(servaddrddd));
    servaddrddd.sin_family = AF_INET;
    servaddrddd.sin_addr.s_addr = htonl(INADDR_ANY);
    servaddrddd.sin_port = htons(6666);

    //bind操作
    if (bind(fd, (const struct sockaddr *) &servaddrddd, sizeof(servaddrddd)) == -1) {
        exit(-1);
    }
    //listen操作
    if (listen(fd, 10) == -1) {
        exit(-1);
    }
    //设置nio
    if(fcntl(fd,F_SETFL,fcntl(fd, F_GETFL,0) | O_NONBLOCK) == -1) {
        exit(-1);
    }

    //这里循环去accept
    for (;;) {
        printf("wait for connect\n");
        len = sizeof(childAddr);
        //使用accept(非阻塞)去获取客户端的新连接, 生成的新cfd也是非阻塞
        cfd = accept(fd, (struct sockaddr *) &childAddr, &len);
        if (cfd == -1) {
            //-1错误可能是accept函数本身出错,也可能是nio没有获取到客户端连接
            //errno是一个包含在中预定义的变量,可以判断最近一个函数调用是否产生了错误,所以这里用errno判断是否正常
            if (errno == EWOULDBLOCK) {
                printf("accept no connect, wait for 2s\n");
                sleep(2);
                continue;
            }
            break;
        }
        //fork函数, 复制本进程生成新的子进程
        if (fork() == 0) {
            printf("inter fork\n");
            //子进程不需要再有父进程的fd引用
            close(fd);
            while(1) {
                //read数据(非阻塞)
                int i = recv(cfd, buff, 1024, MSG_WAITALL);
                if (i == -1 && errno == EWOULDBLOCK) {
                    printf("read cfd:%d no data, wait for 2s\n", i);
                    sleep(2);
                    continue;
                }
                else if (i == -1) {
                    close(cfd);
                    return;
                }
                printf("recv msg from client: %s\n", buff);
                //write数据(非阻塞)
                write(cfd, buff, i);
                close(cfd);
                break;
            }
        }
        //新客户端链接已经在子进程中处理,父进程不需要持有子进程的cfd引用
        close(cfd);
    }
}

int main() {
    nioServer();
    return 0;
}

NIO模型:

image.png

通过以上代码,我们知道了如果设置了一个文件描述符为非阻塞,那么需要手动while()循环(轮询)去判断各个IO操作是否准备好,相比NIO来说,这么写增加了代码的复杂度、空跑了很多CPU、而且有线程的sleep()操作,还影响实时效率,但是不要忘记我们的初衷,我们想要一个线程去处理多个IO事件,只有设置了非阻塞,才会有可能实现线程复用的优势。

单线程处理轮询多个非阻塞IO的代码就不再写了,有了非阻塞,相信大家都能写出来。

实际过程中,NIO线程轮询的模型几乎很少用到,因为为了能让线程复用,它牺牲的太多了,更不能忍受的就是每个应用程序都要去设计这一套忙轮询机制,更多细节繁琐难以处理,比如:轮询多久合适?IO多了选择什么数据类型去存储?。相比之下,身为程序员的我们还是希望能像BIO那样简单阻塞处理,如果有事件来了直接跳过阻塞继续执行就好。这么一劳永逸的事情,操作系统还是帮我们实现了,那就是多路复用

多路复用IO模型与例子

接下来我们讲select函数的多路复用,它的内部实现不仅仅是忙轮询设计这么简单,如果仅仅是忙轮询,那么还是会空跑CPU,它还包括wait()等待和notify()通知机制,可以有效的让其他程序利用select()等待的这段时间。但是对于使用方来看,我们的线程只要等待就可以,首先我们先看下位于unistd.h下的select函数:

/**
 * nfds:sets的文件描述符最大值
 * readfds:fd_set类型,包含了需要检查是否可读的描述符,输出时表示哪些描述符可读。
 * writefds:fd_set类型,包含了需要检查是否可写的描述符,输出时表示哪些描述符可写。
 * errorfds:fd_set类型,包含了需要检查是否出错的描述符,输出时标识哪些描述符错误。
 * timeout:最大等待时间
 * return int:返回可以操作的描述符个数,超时返回0,出错返回-1
 */
int select(int nfds, fd_set* readfds, fd_set* writefds, fd_set* errorfds, struct timeval* timeout);

我们可以发现,select函数中最为关键的就是它的文件描述符,它是set集合,存放哪些需要检查的文件描述符,为了维护fd_set,也有四个宏来操作它:

  • FD_SET():将指定的文件描述符存放到set中。
  • FD_CLR():将指定的文件描述符从set中移除。
  • FD_ZERO():初始化set为空。
  • FD_ISSET():判断指定文件描述符是否存在set中。

以下是用select来实现线程多路复用的逻辑:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

void selectIO();

int main() {
    selectIO();
    return 0;
}

void selectIO() {
    int server_sockfd, client_sockfd;
    int server_len, client_len;
    struct sockaddr_in server_address;
    struct sockaddr_in client_address;
    int result;
    fd_set readfds, testfds;
    //new socket
    server_sockfd = socket(AF_INET, SOCK_STREAM, 0);
    server_address.sin_family = AF_INET;
    server_address.sin_addr.s_addr = htonl(INADDR_ANY);
    server_address.sin_port = htons(8707);
    server_len = sizeof(server_address);
    bind(server_sockfd, (struct sockaddr *) &server_address, server_len);
    listen(server_sockfd, 5);
    //初始化fds, 它是多个文件描述符列表, 也是维持select可以同时处理多个IO的基础
    FD_ZERO(&readfds);
    //将服务端文件描述符加入到set中
    FD_SET(server_sockfd, &readfds);

    while (1) {
        char message[1024];
        char respMessage[] = "HTTP/1.1 200\nContent-Type: text/plain\n\nOK";
        int fd;
        int nread;
        testfds = readfds;
        printf("server waiting\n");

        //select阻塞直到文件描述符set中至少有一个IO可用的为止, 最后一个参数timeout可以设置阻塞时长
        //我们只关心可读set,可写set一般都不用关心
        result = select(FD_SETSIZE, &testfds, (fd_set *) 0, (fd_set *) 0, (struct timeval *) 0);
        if (result < 1) {
            perror("server happen error\n");
            exit(1);
        }

        //扫描所有的文件描述符,找到可用的文件描述符
        for (fd = 0; fd < FD_SETSIZE; fd++) {
            //找到相关文件描述符
            if (FD_ISSET(fd, &testfds)) {
                //如果是serverFd那么肯定只有一个accept()操作
                if (fd == server_sockfd) {
                    client_len = sizeof(client_address);
                    //accept()获取一个可用的连接(一定是已经准备好的, 不会阻塞),生成一个新的客户端文件描述符放到set中
                    client_sockfd = accept(server_sockfd, (struct sockaddr *) &client_address, &client_len);
                    FD_SET(client_sockfd, &readfds);
                    printf("adding client on fd %d\n", client_sockfd);
                }
                    //客户端连接,fork出子进程来处理业务读写
                else {
                    if (fork() == 0) {
                        //取得数据量交给nread
                        ioctl(fd, FIONREAD, &nread);
                        if (nread == 0) {
                            //客户数据请求完毕,关闭套接字,从集合中清除相应描述符
                            close(fd);
                            printf("removing by client on fd %d/n", fd);
                        } else {
                            //一定可读,不会阻塞
                            read(fd, &message, 1024);
                            printf("recv client on fd %d, message:%s\n", fd, message);
                            write(fd, respMessage, strlen(respMessage));
                            close(fd);
                        }
                    }
                    //直接clean, 如果业务没有处理完毕,可以在子进程中重新添加该文件描述符到set中
                    FD_CLR(fd, &readfds);
                    close(fd);
                }
            }
        }
    }
}

IO复用模型:


image.png

上述程序是一个简单的单线程IO多路复用 + 多线程业务处理的IO模型。由main线程去执行select()函数并阻塞,建立连接后的子文件描述符的读写事件也仍注册到main线程的select()中。当有读写事件发生时,由线程池负责去处理。

流程图如下:

image.png

另外还有一种比较常见的IO复用模型是:多线程IO多路复用和业务处理。有效的避免了单个select()最大文件描述符限制不足的场景:

image.png

你可能感兴趣的:(SOCKET与NIO)