一、网络编程关注的四个方面
网络编程主要关注四个问题:连接的建立、断开连接、消息到达、消息发送。
不管使用什么样的网络模型,不管使用的是阻塞IO还是非阻塞IO,不管是同步IO还是异步IO,都需要关注这四个问题。
1.1、建立连接
连接有两种:服务器处理接收客户端的连接;服务器作为客户端主动连接第三方服务。
1.1.1 接收连接
接收连接主要使用accept()函数,用于从全连接队列中返回一个已完成的连接。如果成功,返回值大于0表示与一个客户端TCP建立了连接;返回值是由kernel自动生成的一个全新描述符。在非阻塞模式下,accept()返回-1表示全连接队列中没有已完成的客户端接入。
accept函数原型:
ACCEPT(2) Linux Programmer's Manual ACCEPT(2)
NAME
accept, accept4 - accept a connection on a socket
SYNOPSIS
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
简单示例:
#include <stdio.h>
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
#include <arpa/inet.h>
#define LISTEN_PORT 8888
int main()
{
int listenfd=socket(AF_INET,SOCK_STREAM,0);
struct sockadd_in serveraddr;
memset(&serveraddr,0,sizeof(serveraddr));
serveraddr.sin_family=AF_INET;
serveraddr.sin_addr.s_addr=htonl(INADDR_ANY);
serveraddr.sin_port=htons(LISTEN_PORT);
bind(listenfd,(struct sockaddr *)&serveraddr,sizeof(serveraddr));
while(1)
{
struct sockaddr_in clientaddr;
socklen_t len=sizeof(clientaddr);
clientfd=accept(listenfd,&clientaddr,&len);
/*......
* 处理逻辑代码
*/
}
return 0;
}
1.1.2 主动连接
主动连接由connect()函数发起,主动连接服务器。成功返回0;失败则返回-1,并设置了全局变量errno,应该处理connect函数返回的错误码。
connect函数原型:
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
/*
* sockfd:socket文件描述符
* addr:指定服务器端地址信息,包括IP地址和端口。
* addrlen:指定地址信息的大小
*/
connect()和bind()参数形式一样,区别在于bind()参数的地址信息是自己的,connect()参数的地址信息是对方的地址信息。
失败时返回的错误码:
错误码 | 含义 |
---|---|
EACCES,EPERM | 用户在未启用套接字广播标志的情况下尝试连接到广播地址,或者由于本地防火墙规则,连接请求失败。 |
EADDRINUSE | 本地地址已在使用中。 |
EADDRNOTAVAIL | 套接字未绑定到地址,在尝试将其绑定到临时端口时,确定临时端口范围内的所有端口号当前都在使用中。 |
EAFNOSUPPORT | 传递的地址在其sa_family字段中没有正确的地址族。 |
EAGAIN | 路由缓存中的条目不足。 |
EALREADY | 套接字是非阻塞的,以前的连接尝试尚未完成。 |
EBADF | 文件描述符不是描述符表中的有效索引。 |
EconRefuse | 没有人监听远程地址。 |
EFAULT | 套接字结构地址在用户的地址空间之外。 |
EINPROGRESS | 套接字是非阻塞的,无法立即完成连接。 |
EINTR | 系统调用被捕获的信号中断;参见信号(7)。 |
EISCONN | 套接字已连接。 |
ENETUNREACH | 网络无法访问。 |
ENOTSOCK | 文件描述符sockfd不引用套接字。 |
EPROTOTYPE | 套接字类型不支持请求的通信协议。例如,在尝试将UNIX域数据报套接字连接到流套接字时,可能会发生此错误。 |
ETIMEDOUT | 尝试连接时超时。服务器可能太忙,无法接受新连接。注意,对于IP套接字,当服务器上启用Syncookie时,超时可能很长。 |
简单示例:
#include <stdio.h>
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
#include <arpa/inet.h>
#define LISTEN_PORT 8888
int main()
{
int connectfd=socket(AF_INET,SOCK_STREAM,0);
struct sockaddr_in serveraddr;
memset(&serveraddr,0,sizeof(serveraddr));
serveraddr.sin_family=AF_INET;
serveraddr.sin_addr.s_addr=htonl("127.0.0.1");//服务器IP
serveraddr.sin_port=htons(LISTEN_PORT);//服务器端口
int ret = connect(connectfd,(struct sockaddr *)&serveraddr,sizeof(serveraddr));
if(ret ==1)
{
// ret == -1 && errno == EINPROGRESS 正在建立连接
// ret == -1 && errno = EISCONN 连接建立成功
switch(errno)
{
/*处理错误码*/
}
}
/*处理逻辑*/
}
1.2 断开连接
断开分两种,主动断开和被动断开。
1.2.1 主动断开
主动断开主要调用close()函数。有些网络编程需要支持半关闭状态时,使用shutdown()函数。
close函数原型:
#include <unistd.h>
int close(int fd);
close()关闭文件描述符,使其不再引用任何文件,并可重复使用。成功返回0;失败则返回-1,并设置了全局变量errno。
失败错误码:
错误码 | 含义 |
---|---|
EBADF | fd不是有效的打开文件描述符。 |
EINTR | close()调用被信号中断 |
EIO | 发生I/O错误。 |
shutdown函数原型:
#include<sys/socket.h>
int shutdown(int fd,int flag);
成功则返回0, 失败返回-1, 错误码放在errno。
flag参数说明:
参数 | 含义 |
---|---|
SHUT_RDWR | 值为2,表示关闭读写段 |
SHUT_WR | 值为1,表示关闭本地写段,对端读段 |
SHUT_RD | 值为0,表示关闭本地读段,对端写段 |
使用方式:
//主动关闭
close(fd);
shoutdown(fd,SHUT_RDWR);
// 主动关闭本地读端,关闭对方写端
shutdown(fd,SHUT_RD);
// 主动关闭本地写端,关闭对方读端
shutdown(fd,SHUT_WR);
1.2.1 被动断开
主要依据recv/read、send/write判断。有的网络编程需要支持半关闭状态。
/*......*/
char buffer[1024]={
0 };
// 被动,读端被关闭
int ret=recv(fd,buffer,1024,0);
if(ret==0)
{
close(fd);
}
/*......*/
//被动,写端关闭
ret =send(fd,buffer,1024,0);
if(ret==0 && errno == EPIPE)
{
close(fd);
}
/*......*/
recv和send函数原型:
#include <sys/types.h>
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, void *buf, size_t len, int flags);
成功返回接收 / 发送的字节数;失败则返回-1,并设置errno以指示错误。
注意,recv也可能返回0。当流套接字对等端执行有序关闭时,返回值将为0;不同域(例如UNIX和Internet域)中的数据报套接字允许零长度数据报,当接收到这样的数据报时,返回值为0;如果从流套接字接收的请求字节数为0,则也可以返回值0。
recv的错误码:
错误码 | 含义 |
---|---|
EAGAIN,EWOULDBLOCK | 套接字标记为非阻塞,接收操作要求阻塞,或者设置了接收超时,并且在接收数据之前超时。 |
EBADF | 参数sockfd是无效的描述符。 |
ECONREFUSED | 远程主机拒绝允许网络连接(通常是因为它没有运行请求的服务)。 |
EFAULT | 接收缓冲区指针指向进程地址空间之外。 |
EINTR | 在任何数据可用之前,发送信号中断了接收。 |
EINVAL | 传递的参数无效。 |
ENOMEM | 无法为recvmsg()分配内存。 |
ENOTCONN | 套接字与面向连接的协议关联,尚未连接。 |
ENOTSOCK | 文件描述符sockfd不引用套接字。 |
send错误码:
错误码 | 含义 |
---|---|
EACCES | 对目标套接字文件的写入权限被拒绝,或者对路径前缀为的目录之一的搜索权限被拒绝。(对于UDP套接字)尝试发送到网络/广播地址,好像它是单播地址一样。 |
EAGAIN,EWOULDBLOCK | 套接字标记为非阻塞,请求的操作要求阻塞。 |
EAGAIN | sockfd引用的套接字以前未绑定到地址,在尝试将其绑定到临时端口时,确定临时端口范围内的所有端口号当前都在使用中。 |
EBADF | 指定的描述符无效。 |
EconReset | 对等端重置连接。 |
EDESTADDRREQ | 套接字不是连接模式,并且未设置对等地址。 |
EFAULT | 为参数指定了无效的用户空间地址。 |
EINTR | 在传输任何数据之前发生的信号。 |
EINVAL | 传递的参数无效。 |
EISCONN | 连接模式套接字已连接,但指定了收件人。(现在要么返回此错误,要么忽略收件人规范。) |
EMSGSIZE | 套接字类型要求以原子方式发送消息,而要发送的消息的大小使得这不可能。 |
ENOBUFS | 网络接口的输出队列已满。这通常表示接口已停止发送,但可能是由瞬时拥塞造成的。(通常情况下,在Linux中不会发生这种情况。当设备队列溢出时,数据包会自动丢弃。) |
ENOMEM | 没有可用内存。 |
ENOTCONN | 未连接套接字,且未指定目标。 |
ENOTSOCK | 文件描述符sockfd不引用套接字。 |
EOPNOTSUPP | flags参数中的某些位不适用于套接字类型。 |
EPIPE | 本地端已在面向连接的套接字上关闭。在这种情况下,进程也将接收一个SIGPIPE,除非设置了MSG_NOSIGNAL。 |
1.3 消息到达
接收消息使用recv / read函数。从缓冲区中读取数据。
//......
while(1)
{
//......
char buffer[1024]={
0 };
int ret =recv(fd,buffer,1024,0);
if(ret<0)// ret==-1
{
if(errno==EINTR || errno == EWOULDBLOCK)
break;
// 四次挥手发送ack之前,还可以发送数据
// send(....)
close(fd);
}
else if(ret==0)
close(fd);
else
{
//处理buffer
}
//......
}
//......
1.4 消息发送
发送消息使用send / write函数。往写缓冲区写数据。
//......
char buffer[1024]={
0 };
//......
int ret = send(fd,buffer,1024,0);
if(ret==-1)
{
if(errno==EINTR || errno == EWOULDBLOCK)
return;
close(fd);
}
//......
二、操作IO
只能使用IO函数进行操作,有两者操作方式:阻塞IO和非阻塞IO。
2.1 操作方式
2.1.1 阻塞模式
一般情况下,fd默认是阻塞的。阻塞模式会阻塞在网络线程。比如,当调用recv,读缓冲区没有数据时,则一直阻塞,直到有数据可读才返回。注意,send函数不是把数据写完了才返回,而是只要写缓冲区有空间给它write数据就返回写成功,而不是写完数据才返回成功。
原理图如下:
2.1.2 非阻塞模式
连接的fd的阻塞属性决定了IO函数是否阻塞。默认情况下fd是阻塞的,要设置非阻塞模式,可以使用一下方式:
//......
int flag = fcntl(fd,F_GETFL,0);
flag|=O_NONBLACK;
fcntl(fd,F_SETFL,flag);
//......
设置了非阻塞模式后,调用IO函数时,不管有没有成功都返回。比如,当调用recv,读缓冲区没有数据时,返回-1,并设置errno,errno应该是EWOULDBLOCK。
原理如下:
2.1.3 两者区别
从上面原理图可以看出,差异主要在数据准备阶段。具体差异在:IO函数在数据未就绪时是否立刻返回。
2.2 非阻塞IO处理方式
2.2.1 建立连接
连接有两种:服务器处理接收客户端的连接;服务器作为客户端主动连接第三方服务。
2.2.1.1 主动连接
当服务器需要连接第三方服务,需要调用connect函数进行连接。
在非阻塞IO中,connect()会一直返回-1,同时设置errno;需要检查errno是EINPROGRESS(正在建立连接)还是EISCONN(已经建立连接)。
示例:
#define SERVER_PORT 8888
//......
struct sockaddr_in serv;
memset(&serv,0,sizeof(serv));
serv.sin_family=AF_INET;
serv.sin_addr.s_addr=htonl("127.0.0.1");//要连接的服务器ip地址
serv.sin_port=htons(SERVER_PORT);
while(1)
{
int ret = connect(fd,(struct sockaddr *)&serv,sizeof(serv));
if(ret==-1 && errno==EISCONN)
{
// ........
break;
}
}
// ......
2.2.1.1 接收连接
服务器通过accept()函数从全连接队列中获得已完成连接的客户端,并返回内核自动生成的文件描述符。
在非阻塞模式中,完成socket()、bind()、listen()的调用后,会循环调用accept()函数,如果返回值大于0,表示获取到一个已完成连接的客户端。
示例:
#define SERVER_PORT 8888
//......
int listenfd=socket(AF_INET,SOCK_STREAM,0);
struct sockaddr_in serv;
memset(&serv,0,sizeof(serv));
serv.sin_family=AF_INET;
serv.sin_addr.s_addr=htonl(INADDR_ANY);
serv.sin_port=htons(SERVER_PORT);
bind(listenfd,(struct sockaddr *)&serv,sizeof(serv));
listen(listenfd,10);
//......
while(1)
{
struct sockaddr_in clientaddr;
socklen_t len=sizeof(clientaddr);
int ret = accept(fd,(struct sockaddr *)&serv,sizeof(serv));
if(ret>0)
{
// ........
break;
}
}
// ......
2.2.2 断开连接
如1.2所描述。
2.2.3 消息到达
在非阻塞模式中,如果读缓冲区没数据,recv/read函数返回-1,并且设置errno为EWOULDBLOCK。如1.3所描述。
2.2.4消息发送
如1.4所描述。
2.3 IO函数说明
IO函数既有检测IO功能也有操作IO功能。
例如:
IO函数 | IO操作功能 | IO检测功能 |
---|---|---|
accept | 从全连接队列中取出一个已完成连接的节点,并返回内核自动生成文件描述符以及客户端的ip地址和端口等信息 | 检测全连接队列中是否有已完成的连接的节点。 |
recv | 从读缓冲区中读取数据到用户态 | 检测读缓冲区是否有数据 |
send | 拷贝数据到写缓冲区 | 检测写缓冲区是否可写 |
注意,IO函数只能检测一条连接就绪的状态以及操作一条连接的IO数据
三、IO多路复用检测IO
IO多路复用不会操作IO,只检测IO的就绪状态。 但是IO多路复用可以检测多个IO的就绪状态。IO多路复用主要有:select、poll、epoll。IO多路复用只能检测比较笼统的事件(比如 读事件、写事件、错误事件),IO函数可以检测具体的事件。
IO多路复用检测IO模型:
以epoll为例,epoll主要有三个函数:epoll_create、epoll_wait、epoll_ctl。
epoll函数原型:
#include <sys/epoll.h>
/*相关数据结构*/
struct eventpoll {
// ...
struct rb_root rbr; // 红黑树,管理 epoll 监听的事件
struct list_head rdllist; // 链表,保存着 epoll_wait返回满⾜条件的事件
// ...
};
struct epitem {
// ...
struct rb_node rbn; // 红⿊树节点
struct list_head rdllist; // 双向链表节点
struct epoll_filefd ffd; // 事件句柄信息
struct eventpoll *ep; // 指向所属的eventpoll对 象
struct epoll_event event; // 注册的事件类型
// ...
};
struct epoll_event {
__uint32_t events; // epollin ,epollout ,epollel(边缘触发)
epoll_data_t data; // 保存 关联数据
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
}epoll_data_t;
/*相关接口*/
int epoll_create(int size);
/*
* op:
* EPOLL_CTL_ADD 添加事件
* EPOLL_CTL_MOD 修改事件
* EPOLL_CTL_DEL 删除事件
*
* event:
* EPOLLIN 注册读事件
* EPOLLOUT 注册写事件
* EPOLLET 注册边沿触发,默认是水平触发
*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
* events[i].event:
* EPOLLIN 触发读事件
* EPOLLOUT 触发写事件
* EPOLLERR 触发错误事件
* EPOLLRDHUP 连接读端关闭
* EPOLLHUP 连接读写端关闭
*
* timeout:
* -1,体现阻塞特性,直到有事件触发才返回
* 0,体现非阻塞特性,立刻返回
* >0,超时时间,最多等待timeout时间,如果还没有事件触发就返回;单位是ms。
*/
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
调用epoll_create会创建一个epoll对象;
调用epoll_ctl添加到epoll中的事件都会与网卡驱动程序建立回调关系,相应事件触发时会调用触发函数(ep_poll_callback),将触发的事件拷贝到双向链表(rdllist)中;
调用epoll_wait会从双向链表中就绪事件拷贝到用户态中。
那么,IO多路复用是怎么检测IO事件的呢?以epoll为例。
3.1 建立连接
连接有两种方式:主动连接和接受连接。
3.1.1 主动连接
主动连接主要通过connect()函数建立。
首先,通过socket()函数创建一个socket对象;
然后,epoll(IO多路复用器)监听写事件,调用connect函数,在三次握手阶段,客户端向服务端发送ack(在第三次)的同时发送写就绪信号给epoll(IO多路复用器);
这就实现了epoll(IO多路复用器)检测到主动连接完成。
3.1.2 接受连接
接受连接主要通过socket()、bind()、listen()、accept()函数。
首先,通过socket()函数创建一个socket对象,bind()绑定地址,listen()监听端口,完成一个listenfd的创建和设置;
其次,epoll(IO多路复用器)监听listenfd的读事件,三次握手成功后全连接队列会产生一个节点,同时发送信号告诉epoll(IO多路复用器),触发读事件;这时说明连接完成。
然后,调用accept()函数,执行操作IO功能。
简单示例:
int init_sock(short port) {
int fd = socket(AF_INET, SOCK_STREAM, 0);
fcntl(fd, F_SETFL, O_NONBLOCK);
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(port);
bind(fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
if (listen(fd, 20) < 0) {
printf("listen failed : %s\n", strerror(errno));
return -1;
}
printf("listen server port : %d\n", port);
return fd;
}
int main()
{
int epfd=epoll_create(1);
int listenfd=init_sock(8888);
struct epoll_event ep_ev = {
0, {
0}};
ep_ev.data.fd=listenfd;
ep_ev.events=EPOLLIN;
epoll_ctl(epfd,EPOLL_CTL_ADD,listenfd,&ep_ev);
while(1)
{
struct epoll_event ep_ev_client[1024];
int n = epoll_wait(epfd,ep_ev_client,1024,-1);
int i=0;
for(i=0;i<n;i++)
{
if(ep_ev_client[i].events & EPOLLIN)
{
//处理读事件......
}
if(ep_ev_client[i].events & EPOLLOUT)
{
//处理写事件......
}
}
}
return 0;
}
3.2 连接断开
IO多路复用器检测的是被动断开。
当epoll返回EPOLLRDHUP表示服务器读端关闭了;当epoll返回EPOLLHUP表示服务器读写端都关闭了。
3.3 消息到达
epoll(IO多路复用器)检测客户端fd的读事件。
当客户端发送数据到服务器的读缓冲区时,会发送信号给epoll(IO多路复用器),epoll(IO多路复用器)就会触发读事件,说明读缓冲区填充有数据;此时就可以调用recv/read函数操作IO。
3.4 消息发送
epoll(IO多路复用器)检测客户端fd的写事件。
当写缓冲区可写(即写缓冲区有空间可以写数据)时,它会发信号告诉epoll(IO多路复用器),epoll(IO多路复用器)触发写事件,这时调用send/write函数操作IO。
四、总结
一定要熟悉网络编程的四个关注点(建立连接、消息到达、消息发送、断开连接),深入理解操作IO和检测IO,这样才能很好的理解网络编程的源码,设计出高效的网络模型。
特别需要理解TCP的三次握手和四次挥手过程。