#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdlib.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#define PORT 39002
#define MAX_FD_NUM 3
#define BUF_SIZE 512
#define ERR_EXIT(m) \
do \
{ \
perror(m); \
exit(EXIT_FAILURE); \
} while (0)
int main()
{
//创建套接字
int m_sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (m_sockfd < 0)
{
ERR_EXIT("create socket fail");
}
//初始化socket元素
struct sockaddr_in server_addr;
int server_len = sizeof(server_addr);
memset(&server_addr, 0, server_len);
server_addr.sin_family = AF_INET;
//server_addr.sin_addr.s_addr = inet_addr("0.0.0.0"); //用这个写法也可以
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
//绑定文件描述符和服务器的ip和端口号
int m_bindfd = bind(m_sockfd, (struct sockaddr *)&server_addr, server_len);
if (m_bindfd < 0)
{
ERR_EXIT("bind ip and port fail");
}
//进入监听状态,等待用户发起请求
int m_listenfd = listen(m_sockfd, MAX_FD_NUM);
if (m_listenfd < 0)
{
ERR_EXIT("listen client fail");
}
//定义客户端的套接字,这里返回一个新的套接字,后面通信时,就用这个m_connfd进行通信
//struct sockaddr_in client_addr;
//socklen_t client_len = sizeof(client_addr);
//int m_connfd = accept(m_sockfd, (struct sockaddr *)&client_addr, &client_len);
printf("client accept success\n");
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
//接收客户端数据,并相应
char buffer[BUF_SIZE];
int array_fd[MAX_FD_NUM];
//客户端连接数量
int client_count = 0;
fd_set tmpfd;
int max_fd = m_sockfd;
struct timeval timeout;
for (int i = 0; i < MAX_FD_NUM; i++)
{
array_fd[i] = -1;
}
//array_fd[0] = m_sockfd;
while (1)
{
FD_ZERO(&tmpfd);
FD_SET(m_sockfd, &tmpfd); // m_sockfd是服务器的socket,也加入到fd_set中,后面交给select进行监听。
int i;
//所有在线的客户端加入到fd中,并找出最大的socket
for (i = 0; i < MAX_FD_NUM; i++)
{
if (array_fd[i] > 0)
{
FD_SET(array_fd[i], &tmpfd); //set array_fd in red_set
if (max_fd < array_fd[i])
{
max_fd = array_fd[i]; //get max_fd
}
}
}
int ret = select(max_fd + 1, &tmpfd, NULL, NULL, NULL);
if (ret < 0)
{
ERR_EXIT("select fail");
}
else if (ret == 0)
{
//ERR_EXIT("select timeout"); //超时不是错误,不可断掉连接
printf("select timeout\n");
continue;
}
//表示有客户端连接
if (FD_ISSET(m_sockfd, &tmpfd))
{
int m_connfd = accept(m_sockfd, (struct sockaddr *)&client_addr, &client_len);
if (m_connfd < 0)
{
ERR_EXIT("server accept fail");
}
//客户端连接数已满
if (client_count >= MAX_FD_NUM)
{
printf("max connections arrive!!!\n");
// char buff[]="max connections arrive!!!";
// send(m_connfd, buff, sizeof(buff) - 1, 0);
close(m_connfd);
continue;
}
//客户端数量加1
client_count++;
printf("we got a new connection, client_socket=%d, client_count=%d, ip=%s, port=%d\n", m_connfd, client_count, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
for (i = 0; i < MAX_FD_NUM; i++)
{
if (array_fd[i] == -1)
{
array_fd[i] = m_connfd;
break;
}
}
}
//遍历所有的客户端连接,找到发送数据的那个客户端描述符
for (i = 0; i < MAX_FD_NUM; i++)
{
if (array_fd[i] < 0)
{
continue;
}
//有客户端发送过来的数据
else
{
if (FD_ISSET(array_fd[i], &tmpfd))
{
memset(buffer, 0, sizeof(buffer)); //重置缓冲区
int recv_len = recv(array_fd[i], buffer, sizeof(buffer) - 1, 0);
if (recv_len < 0)
{
ERR_EXIT("recv data fail");
}
//客户端断开连接
else if (recv_len == 0)
{
client_count--;
//打印断开的客户端数据
printf("client_socket=[%d] close, client_count=[%d], ip=%s, port=%d\n\n", array_fd[i], client_count, inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
close(array_fd[i]);
FD_CLR(array_fd[i], &tmpfd);
array_fd[i] = -1;
}
else
{
printf("server recv:%s\n", buffer);
strcat(buffer, "+ACK");
send(array_fd[i], buffer, sizeof(buffer) - 1, 0);
}
}
}
}
}
//关闭套接字
close(m_sockfd);
printf("server socket closed!!!\n");
return 0;
}
上面也是一个简单的echo服务器,它通过select实现I/O复用。下面我简单介绍这个过程的思想(如果你在读这篇文章时觉得写的不清楚,欢迎留言或私信交流)。
首先,要进行I/O多路复用,则意味着一个服务器可以同时连接多个客户端,那么就需要用数组来记录这些客户端,上面的代码中通过int array_fd[MAX_FD_NUM];
来记录。每当服务器用accept接受一个用户连接,就将accept返回这个客户端连接的文件描述符m_connfd
并放入到array_fd
中。
但是accept、recv、send函数都是阻塞的,如果没有用户发送连接请求或者发送数据,那整个服务器照样会被阻塞,无法实现一对多的连接。这个时候select就发挥作用了。
select能够检测到句柄上是否有事件发生(比如客户连接请求、客户的数据到达服务器)。这样,程序就只需要加上一个判断语句,根据select的结果来决定是否调用accept或者recv。
那么select是怎么做的呢?select有一个配套的数据结构fd_set
,它里面定义了一个数组可以记录文件描述符。服务器中通过FD_SET(m_sockfd, &tmpfd)
将tmpfd
中的第m_sockfd
(它对应服务器对外开放的套接字)标记为感兴趣的文件描述符(将对应的位置1)。如果没有新的连接请求,select会将m_sockfd
在tmpfd
中的记号置为0。select的思想是这样的:遍历tmpfd
中被标记为感兴趣的文件描述符,遍历完后,把有事件产生的文件描述符在tmpfd
中的值保持为1,否则置为0。
所以tempfd
在被select函数处理前后表示的意义是不一样的,在传入前,表示的是需要检查的感兴趣的文件描述符集合,在处理后,表示的是感兴趣的文件描述符集合中有事件发生的文件描述符。
正是由于这个过程中会改变tempfd
表示的感兴趣的文件描述符集合,所以在程序中创建了数组array_fd
来表示感兴趣的文件描述符集合。然后每次select后,通过array_fd
重新设定tempfd
上感兴趣的文件描述符集合。
由于服务器需要频繁的监听是否有新的用户连接请求,所以每次循环都会执行FD_SET(m_sockfd, &tmpfd);
,表示对m_sockfd这个文件描述符感兴趣。
另外,服务器还需要知道是否收到客户端发送的数据,所以在建立服务器-客户端连接后,还需要通过FD_SET(m_connfd, &tmpfd);
将用户连接加入到感兴趣的文件描述符集合中。
每当通过select后,tempfd
变为感兴趣的文件描述符集合中有事件发生的文件描述符集合。再通过一个循环函数遍历tempfd
,根据FD_ISSET(array_fd[i], &tmpfd)
找到有事件发生的文件描述符。并对这个文件描述符执行recv函数,这个时候,recv函数可以直接获得收到的数据,因而不会被阻塞。
需要注意的是,服务器对外开放的文件描述符sock_fd
并不在array_fd
中,每次单独的通过FD_SET(m_sockfd, &tmpfd);
把在tempfd
上的标记置为1。再单独的通过FD_ISSET(m_sockfd, &tmpfd)
检查这个套接字上是否有新的连接请求事件。如果有,则由accept产生客户端对应的文件描述符,并加入到感兴趣的文件描述符集合array_fd
中。此时由于客户端连接请求已经存在,所以accept函数不会被阻塞。
这个服务器程序可以同时处理多个用户连接而不会被阻塞,我觉得它最重要的思想是:不直接去执行会导致阻塞的函数(accept、recv、send),而是在执行前先进行判断,如果有对应的事件发生才去执行阻塞函数,否则继续循环监听。这种方式称为事件驱动
为什么说这种方式实现了I/O多路复用呢?在网上找了一段解释:
IO 多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;
一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;
多路是指网络连接,复用指的是同一个线程
这样实现方式存在一些缺点:
-
首先是
fd_set
中定义的数组大小是由FD_SETSIZE
决定的,最大为1024,所以select的实现方式由连接数量的限制。 -
select会将感兴趣的文件描述符标记集
tempfd
中有事件发生的文件描述符标记为1,但是并不能直接告诉程序到底是哪些文件描述符上有事件发生,实现时还得通过FD_ISSET
函数一个个判断tempfd
中的标记,时间复杂度为O(N)
。 -
select每次处理完
tempfd
后会改变tempfd
中设定的感兴趣的文件描述符标记,程序还得创建一个array_fd
来保存这些感兴趣的文件描述符集合,在select之后再通过for循环依次根据array_fd
重新设置tempfd
,操作繁琐,接口很不友好(不仔细琢磨不容易理解这个设计,差点被select函数挡在了网络编程的门外)。