文章目录
1. C实现socket通信(TCP)
1.1 通信流程
1.2 socket基本操作
socket即是一种特殊的文件,一些socket函数就是对其进行的操作(读/写IO、打开、关闭)
1.2.1 socket()函数
int socket(int domain, int type, int protocol);
socket函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符(socket descriptor),它唯一标识一个socket。这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。
正如可以给fopen的传入不同参数值,以打开不同的文件。创建socket的时候,也可以指定不同的参数创建不同的socket描述符,socket函数的三个参数分别为:domain、type、protocol。
- domain:即协议域,又称为协议族(family)。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。
- type:指定socket类型。常用的socket类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等
- protocol:指定协议。常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等。
注意并不是上面的type和protocol可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当protocol为0时,会自动选择type类型对应的默认协议。
1.2.2 bind()函数
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
bind()函数把一个地址族中的特定地址赋给socket,函数的三个参数分别为:
- sockfd:socket描述字,它是通过socket()函数创建用来唯一标识一个socket。
- addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同。
- 对应的是地址的长度。
通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。
1.2.3 listen()、connect()函数
int listen(int sockfd, int backlog);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
一个服务器会在调用socket()、bind()之后就会调用listen()来监听这个socket,如果客户端这时调用connect()发出连接请求,服务器端就会接收到这个请求。
listen函数的第一个参数即为要监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。
connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数来建立与TCP服务器的连接。
1.2.4 accept()函数
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
TCP服务器端依次调用socket()、bind()、listen()之后,就会监听指定的socket地址了。TCP客户端依次调用socket()、connect()之后就想TCP服务器发送了一个连接请求。TCP服务器监听到这个请求之后,就会调用accept()函数取接收请求,这样连接就建立好了。之后就可以开始网络I/O操作了,即类同于普通文件的读写I/O操作。
accept函数的第一个参数为服务器的socket描述字,第二个参数为指向struct sockaddr *的指针,用于返回客户端的协议地址,第三个参数为协议地址的长度。如果accpet成功,那么其返回值是由内核自动生成的一个全新的描述字,代表与返回客户的TCP连接。
accept的第一个参数为服务器的socket描述字,是服务器开始调用socket()函数生成的,称为监听socket描述字;而accept函数返回的是已连接的socket描述字。一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。
1.2.5 read()、write()函数等
read函数是负责从fd中读取内容.当读成功时,read返回实际所读的字节数,如果返回的值是0表示已经读到文件的结束了,小于0表示出现了错误。如果错误为EINTR说明读是由中断引起的,如果是ECONNREST表示网络连接出了问题。
write函数将buf中的nbytes字节内容写入文件描述符fd.成功时返回写的字节数。失败时返回-1,并设置errno变量。 在网络程序中,当我们向套接字文件描述符写时有俩种可能。1)write的返回值大于0,表示写了部分或者是全部的数据。2)返回的值小于0,此时出现了错误。我们要根据错误类型来处理。如果错误为EINTR表示在写的时候出现了中断错误。如果为EPIPE表示网络连接出现了问题(对方已经关闭了连接)。
常见的网络I/O操作函数有下面几组:
- read()/write()
- recv()/send()
- readv()/writev()
- recvmsg()/sendmsg()
- recvfrom()/sendto()
1.2.6 close()函数
int close(int fd);
在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。
epoll介绍
epoll模型的优点
- 支持一个进程打开大数目的socket描述符
- IO效率不随FD数目增加而线性下降
- 使用mmap加速内核与用户空间的消息传递
epoll的两种工作模式
- LT(level-triggered):是epoll的默认模式
socket接收缓冲区不为空 有数据可读 读事件一直触发
socket发送缓冲区不满 可以继续写入数据 写事件一直触发 - ET(edge-triggered):
socket的接收缓冲区状态变化时触发读事件,即空的接收缓冲区刚接收到数据时触发读事件
socket的发送缓冲区状态变化时触发写事件,即满的缓冲区刚空出空间时触发读事件
epoll模型API
#include <sys/epoll.h>
/* 创建一个epoll的句柄,size用来告诉内核需要监听的数目一共有多大。当创建好epoll句柄后,
它就是会占用一个fd值,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。*/
int epoll_create(int size);
/*epoll的事件注册函数*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*等待事件的到来,如果检测到事件,就将所有就绪的事件从内核事件表中复制到它的第二个参数events指向的数组*/
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
epoll的事件注册函数epoll_ctl,第一个参数是 epoll_create() 的返回值,第二个参数表示动作,使用如下三个宏来表示:
POLL_CTL_ADD //注册新的fd到epfd中;
EPOLL_CTL_MOD //修改已经注册的fd的监听事件;
EPOLL_CTL_DEL //从epfd中删除一个fd;
struct epoll_event 结构如下:
typedef union epoll_data
{
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
} epoll_data_t;
struct epoll_event
{
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
epoll_event结构体中的events 可以是以下几个宏的集合:
EPOLLIN //表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT //表示对应的文件描述符可以写;
EPOLLPRI //表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR //表示对应的文件描述符发生错误;
EPOLLHUP //表示对应的文件描述符被挂断;
EPOLLET //将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT//只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。
实验演示
编译epoll_server.c和epoll_client.c
gcc epoll_server.c -o epoll_server
gcc epoll_client.c -o epoll_client
运行截图如下:
核心代码:
while (1)
{
if ((nCounts = epoll_wait(epfd, events, MAXSIZE, -1)) < 0)
{
perror("epoll_ctl");
exit(-1);
}
else if (nCounts == 0)
{
printf("time out, No data!\n");
}
else
{
for (int i = 0; i < nCounts; i++)
{
int tmp_epoll_recv_fd = events[i].data.fd;
if (tmp_epoll_recv_fd == i_listenfd) // 有客户端连接请求
{
if ((i_connfd = accept(i_listenfd, (struct sockaddr *)NULL, NULL)) < 0) // 阻塞等待客户端连接
{
perror("accept");
}
else
{
printf("Client[%d], welcome!\n", i_connfd);
}
ev.events = EPOLLIN;
ev.data.fd = i_connfd;
if (epoll_ctl(epfd, EPOLL_CTL_ADD, i_connfd, &ev) < 0)
{
perror("epoll_ctl");
exit(-1);
}
}
else // 若是已连接的客户端发来数据请求
{
// 接受客户端发来的消息并作处理(小写转大写)后回写给客户端
memset(msg, 0, sizeof(msg));
if ((nrecvSize = read(tmp_epoll_recv_fd, msg, MAXSIZE)) < 0)
{
perror("read");
continue;
}
else if (nrecvSize == 0) // read返回0代表对方已close断开连接。
{
printf("client has disconnected!\n");
epoll_ctl(epfd, EPOLL_CTL_DEL, tmp_epoll_recv_fd, NULL);
close(tmp_epoll_recv_fd); //
continue;
}
else
{
printf("recvMsg:%s", msg);
/*for (int i = 0; msg[i] != '\0'; i++)
{
msg[i] = toupper(msg[i]);
}*/
if (write(tmp_epoll_recv_fd, msg, strlen(msg) + 1) < 0)
{
perror("write");
}
}
}
}
}
} // while
我们我们调用epoll_wait()函数的时候,系统创建一个epoll对象,每个对象都有一个叫做eventpoll类型的结构体与之对应,该结构体中主要有两个主要的成员,一个是rbn,代表将要通过epoll_ctl向epll对象中添加的事件。这些事件都是挂载在红黑树中。一个是rdlist,里面存放的是将要发生的事件。当我们使用epoll_ctrl()函数的时候,就是向epoll对象中添加,删除,修改感兴趣的事件。通过epoll_wait()调用收集在epoll监控中已经发生的事件。当监控的事件状态发生改变的时候,我们会调用函数把epitem加入到rdlist中去。
源码地址
链接: gitee地址
总结
本文总结了 socket,epoll 的特点,简短的实现了基于epoll的socket并发通信。在网络程序设计这门课中,孟宁老师深入浅出,生动有趣的讲解让我们了解了Javascript网络编程,Socket API,网络协议设计及RPC、Linux内核网络协议栈,受益匪浅。