1. 套接字(SOCKET)相关概念
网络套接字的基本操作:创建(socket)、命名(bind)、侦听(listen)、连接(accept)、关闭(shutdown)、发送(send)、接受(recv),以上这几种操作均是系统调用。
服务端
服务端通过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
相关流程
2. C语言中的SOCKET与NIO
c语言最纯粹,最接近底层的系统调用,可以不留余地的欣赏完真实socket的每个细节。
socket编程的函数
- 创建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)
- 命名bind
/**
* sockfd:套接字描述符(socket句柄)
* addr: 指向通用套接字的协议地址结构,包括协议、地址和端口等信息
* addrlen: 协议地址结构的长度
* return: 0 成功; -1 失败
*/
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
- 监听listen
/**
* sockfd: socket句柄
* backlog:sockfd接收连接的最大数目
* return: 0 成功; -1 失败
*/
int listen(int sockfd, int backlog);
- 连接accept
/**
* sockfd: socket句柄
* addr: addr指向通用套接字的协议地址结构,包括协议、地址和端口等信息
* addrlen: 协议地址结构的长度,一般为sizeof(sockaddr_in)
* return: 创造返回一个新的socket与客户进程通信,原sockfd仍用于套接字侦听
*/
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- 接收recv
/**
* sockfd:与远程通信连接的套接字描述符
* buf:接收数据的缓冲区地址
* len:缓冲区长度
* flags:接收标志
*/
int recv(int sockfd, void *buf, size_t len, int flags);
- 读取read
/**
* fd:套接字文件描述符
* buf:要接收的字符数组
* nbyte:最大读取的字节
*/
int read (int __fd, void *__buf, size_t __nbyte);
- 写入write
/**
* fildes:套接字文件描述符
* buf:要接收的字符数组
* nbyte:写入字节
*/
int write(int fildes, const void *buf, int nbyte);
BIO模型与例子
我们接下来写一个简单的服务端接收客户端请求并相应的程序,包含了server socket
整个的生命周期:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
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<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include <unistd.h>
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
模型:
BIO
程序到此结束,BIO
的阻塞进程/线程的特点已经在程序标出:在accept
和read
的时候会阻塞。每当有一个客户端来连接,就要有一个线程/进程去阻塞,线程/进程对于操作系统来说是十分有限的,所以当客户端并发上到十万、百万级别的时候会迅速消耗完系统的资源。
NIO模型与例子
接下来我们讨论NIO
,也就是非阻塞,引入非阻塞的目的就是解决阻塞操作过程中,避免创建大量线程去等待各自的IO
操作;因为引入了非阻塞,完全可以使用一个线程去处理多个阻塞IO,这样线程的利用率就大大提升。
对于client socket
(connect、read、write
)和server socket
(accept、read、write
)来说,只要将其文件描述符设置成no_blocked
,那么它的IO
操作函数就可以不必等待,直接返回结果(可能有数据,也可能没有数据)。
客户端一般不涉及到大并发操作(其实是和其他io
函数一样的),所以我们只讨论server socket
的NIO
操作:accept
、read
、write
,常用场景中write
操作和网卡相关,一般不会阻塞,为了简化逻辑,我们先拿accept
、read
两个函数举例。
对于read
操作来说,其实把网卡的数据拷贝到进程内存上速度是非常之快的,真正时间瓶颈是花在等待网卡把数据准备好的过程上,也就是上述说的IO
等待的过程(accept
操作是等待TCP
连接建立,也是等待IO
的过程)。
c
语言socket
编程中,我们可以使用fcntl
函数将某个文件描述符设置为非阻塞:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<unistd.h>
#include<fcntl.h>
#include <errno.h>
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.h>中预定义的变量,可以判断最近一个函数调用是否产生了错误,所以这里用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
模型:
通过以上代码,我们知道了如果设置了一个文件描述符为非阻塞,那么需要手动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 <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
#include <netinet/in.h>
#include <sys/time.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <stdlib.h>
#include <memory.h>
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复用模型:
上述程序是一个简单的单线程IO多路复用 + 多线程业务处理的IO模型。由main
线程去执行select()
函数并阻塞,建立连接后的子文件描述符的读写事件也仍注册到main
线程的select()
中。当有读写事件发生时,由线程池负责去处理。
流程图如下:
另外还有一种比较常见的IO复用模型是:多线程IO多路复用和业务处理。有效的避免了单个select()
最大文件描述符限制不足的场景: