【学习记录】简单的Server端服务器模型的搭建【网络编程学习阶段汇总】

前一阵子想写一个服务器,嗯,一开始是想写一个电商平台来着.............

然后就开始学,慢慢的觉得自己需要学习的东西真的还有很多很多,比如用长连接还是短连接,长连接的话怎么节省系统开销,心跳包的设置,避免产生大量小包的nagle算法,html的token怎么用,还有cookie,io复用,计时器,数不清的内部算法........So,坐下来学!

然后前天写出了这个服务器的基本的样子,昨天调试时候把bug改了改,于是一个基本模型算是出来了(嗯一个没写计时器的破模型.......最近打算把计时器写一写)。

下面写一下我的学习过程。


套接字框架

首先编程中通信的基本是使用套接字。套接字分为客户端和服务端。

服务端常见的流程是socket() ->bind() ->listen() ->accept()这几个函数。

(当然也有例外,比如有一种方法是客户端进行bind之后connect,这里暂时不谈。)

【学习记录】简单的Server端服务器模型的搭建【网络编程学习阶段汇总】_第1张图片


socket函数

socket函数指定地址描述,套接字类型,指定协议并返回一个描述符。地址描述一般来说只能选择使用AF_INET;套接字类型有tcp,udp和原始套接字比较常用,这里我使用了tcp协议,所以参数是SOCK_STREAM;最后一个参数可以不设置,我直接将其置为0。


bind函数

bind函数将刚才返回的文件描述符绑定在一个端口。并设置一个sockaddr_in变量存储服务器本身的IP和端口等信息。


listen函数

listen函数对刚才绑定的端口进行监听,并设置一个队列长度。该队列长度事实上在内核中控制了两个队列,分别是未连接队列和已连接队列。未连接队列储存的是已经发送连接请求,但是尚未完成三次握手的客户连接,当该队列中的连接完成三次握手之后,将进入已连接队列的尾部;已连接队列储存的是已经完成三次握手但是尚未被accept函数接收的客户连接。


accept函数

accept函数从已连接队列队首获取连接,将该连接的对端信息(IP和端口等)储存在一个sockaddr_in变量中,并返回一个描述符(此次客户连接的描述符)。


端口重用

作为一个服务器,当然不能随意ctl+c掉,但是如果服务被关闭(比如服务器崩了)之后,立即重新启动服务,会出现一个无法绑定端口的错误。这是因为刚才的端口处在了TIME_WAIT的状态,内核中该状态将会维护两个MSL(maximum segment lifetime)的时间,linux下大约是1分钟左右,在此期间该端口不可再次绑定。

但是我们是服务器,关闭后重启的每一秒钟都很紧迫,所以当然不能让内核白白浪费这两个MSL的时间。所以我们使用setsockopt函数,对其设置端口重用。设置之后,该端口将立即可以重用。

具体方法:

setsockopt()函数有5个参数:分别是描述符,套接字接口类型,选项名称,选项值和选项名称。

我们重点关注前三个:

第一个:描述符,就是socket时候创建的那个描述符,服务描述符。

第二个:套接字接口类型,看下面这个表格:

套接字接口类型
SOL_SOCKET 基本套接口
IPPROTO_IP  IPv4套接口
IPPROTO_IPV6 IPv6套接口
IPPROTO_TCP TCP套接口

我们这里使用SOL_SOCKET参数。

第三个:选项名称。这个是重点,此参数有很多选项,我们使用参数SO_REUSEADDR,也就是端口重用(好吧翻译过来是地址重用,不过不要在意这个翻译了~)。

然后第四个和第五个,指向变量的指针和该指针的空间长度,我直接设置的NULL和1。


并发多进程

当然了作为一个服务器,不可能只有一个客户端来连你,所以我们要处理一些并发的访问。
最简单的办法就是使用fork函数对每个客户连接都创建一个进程。
客户进程关闭服务描述符,只处理客户描述符;
服务进程则关闭刚刚的客户描述符连接,只监听连接请求。
如下图:
/*伪代码*/

listen(listen_fd, MAX_QUEUE);
while(1)
{
	client_fd = accpet(listen_fd, client_addr, sizeof(	sockaddr_in));
	//客户连接进程
	fi( fork() == 0)
	{
		close(listen_fd);
		while( client_request(client_fd) );
		close(client);
	}
	//服务器进程
	else
		close(client_fd);

}

当然了这种架构的缺点也很明显:每个客户连接都需要分配一个独立的进程,系统开销太大,来个几千并发就挂了。

所以我们要想其他办法。


IO复用

服务器的访问连接数以万计,但并非是所有的用户每时每刻都是活动的,上面的fork架构中,很多进程都是阻塞在读用户数据包那里。所以,我们可以在这里引入IO复用。
这里我们涉及到几个概念:

阻塞IO与非阻塞IO
阻塞IO 没有文件可读,则阻塞一直在此处
非阻塞IO 没有文件可读,立即返回errno=EAGAIN。

同步与异步
同步IO 数据进行读写时候,阻塞。
异步IO 数据读写时候不阻塞,完成时通过事件进行通知。

然后了解到IO复用有三种常见的方法,分别是select,poll和epoll。这里我使用的时epoll,所以就主要说一下epoll的基本用法。
因为epoll有两种工作模式,水平触发LT(默认模式) 和边缘触发ET(高效模式),但是边缘触发的要求是当前的描述符是非阻塞的(nonblocking),所以我们要手动将描述符设置一下。

设置非阻塞:set_non_blocking函数
这个函数基本就涉及到一条关键语句,就先直接列出代码:
/* 
set_non_blocking - 设置描述符为非阻塞 
*/  
int set_non_blocking(int sockfd)  
{  
    if (fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFD, 0)|O_NONBLOCK) == -1) {  
        return -1;  
    }  
    return 0;  
}  
然后再来分析一下这条挺长的语句,这条语句中包涵了内外两层fcntl()函数。 我因为一开始不知道这个函数的作用,就去查了一下资料,得到函数原型是:int fcntl(int fd, int cmd, long arg / struct flock *lock),
第一个参数是待设置的文件描述符;
第二个参数是命令参数,参数比较多,这条语句中涉及到了两个,分别是:
F_GETFL  取得文件描述词状态旗标,此旗标为open()的参数flags。
F_SETFL  设置描述词状态旗标,参数arg作为新旗标,但只允许O_APPEND、
O_NONBLOCK和O_ASYNC位的改变,其他位的改变将不受影响
第三个参数,因为第二个参数不同而不同,我这里用到一次0和一次long arg参数。

好了回到刚才的语句:
fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFD, 0)|O_NONBLOCK)
现在就比较好理解了,内层的fcntl函数调用,获取了sockfd描述符的flag(所以内层的第三个参数是0),然后在外层fcntl函数获取到了这个flag,并将其设置为O_NONBLOCK,即非阻塞。

于是将描述符设置非阻塞完成。
下面继续看epoll有关的函数。

epoll_create函数
函数原型:int epoll_create( int size )
看函数名就知道这是干啥的了,创建一个epoll,有一个参数MAXEVENTS,指定epoll监控的最大事件个数。返回值是创建的epoll的描述符。

epoll_ctl函数
函数原型:int epoll_ctl( int epfd, int op, int fd, struct epoll_event *event )
epoll的控制函数,对epoll中的描述符进行控制。有四个参数,作用分别是:
第一个参数是epoll事件的描述符;
第二个参数是控制命令,有三个,分别是    
EPOLL_CTL_ADD 注册新的fd到epfd中
EPOLL_CTL_MOD 修改已经注册的fd的监听事件
EPOLL_CTL_DEL 从epfd中删除一个fd
第三个参数是控制事件的描述符;
第四个参数是告知内核要监听什么事。
其中这个结构体epoll_event的结构是:
struct epoll_event
 {  
  uint32_t   events;    /* Epoll events */  
  epoll_data_t data;      /* User data variable */  
};
其中又包含一个名为epoll_data_t的共用体:
typedef union epoll_data 
{  
  void    *ptr;  
   int      fd;  
   uint32_t u32;  
   uint64_t u64;  
} epoll_data_t;  
epoll_event的成员uint32_t events的作用是储存监听的内容,可以是以下几个宏的集合:
EPOLLIN 表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
EPOLLOUT 表示对应的文件描述符可以写
EPOLLPRI 表示对应的文件描述符有紧急的数据可读(有带外数据到来)
EPOLLERR 表示对应的文件描述符发生错误
EPOLLHUP 表示对应的文件描述符被挂断
EPOLLET 将EPOLL设为边缘触发(Edge Triggered)模式,
这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT        只监听一次事件,当监听完结束后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里。

epoll_wait函数
函数原型:int epoll_wait( int epfd, struct epoll_event *events, int maxevents, int timeout )
该函数的作用是对所有在epoll中的事件描述符进行监听,一旦有事件发生或者到达计时器的时间,那么该函数返回有事件发生的描述符的个数;并将发生事件的描述符按顺序压到一个events[]数组中。
该函数有四个参数:
第一个:epoll的描述符
第二个:储存响应事件的events数组
第三个:现在epoll内监听的描述符数量
第四个:计时器规定返回的时间,若设置为-1,则一直阻塞到有事件发生。

OK写完了这三个要用的IO复用函数,那么该怎么用呢...?
这么用:
/*伪代码*/

listen();
bind();
listen();
epoll_create(MAX_EVENTS);
event_act = set(listen_fd,  EPOLLIN | EPOLLET);    //设置:边缘触发 和 可读时通知
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &event_act);
while(1)
{
    get_act_fds = epoll_wait(epoll, events, curfds, -1);
    for(i = 0; i<get_act_fds; i++)
    {    
        if(events[i] == listen_fd)
        {
            client_fd = accept();
            event_act = set(client_fd,  EPOLLIN | EPOLLET); 
            epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event_act);
        }
        else
        {
            still_connect = client_request(events[n].data.fd)
            if(!still_connect)
                close(client_fd);
        }
    }
}

好,epoll在这个服务器里的使用流程基本是这样的。

注意:
1.在client_request函数中,传入的参数应该是events[i].data.fd,而不是client_fd,否则会出现只响应最新的连接描述符的bug。
2.timeout设为-1时,是否出现动作一次就返回一次,并且返回的描述符只有一个?
     回答:查资料发现epoll_wait()第四个参数的计时器精度是微妙(10^-6秒)。单台机器测试时,因为效率瓶颈达不到微妙级别的多并发连接,所以设置timer=-1后,每次返回的events[]队列长度都是1,一度认为设置timer=-1时不需要把events[]设为队列,当然,这是错的。因为企业级的并发可以轻松达到100w(10^6)级别,所以这时候timer的精度也是不够的,因此events[]还是要设为一个队列。

读取数据

然后我的服务器现在客户连接的描述符都是非阻塞的,所以read()的时候要注意一点:
在UNIX/LINUX下,非阻塞模式SOCKET可以采用recv+MSG_PEEK的方式进行判断,其中MSG_PEEK保证了仅仅进行状态判断,而不影响数据接收。
对于主动关闭的SOCKET,
  recv返回-1,而且errno被置为9(#define EBADF 9 /* Bad file number */)
        或 104 (#define ECONNRESET 104 /* Connection reset by peer */);
对于被动关闭的SOCKET,
  recv返回0,而且errno被置为11(#define EWOULDBLOCK EAGAIN /* Operation would block */);
对正常的SOCKET,
  如果有接收数据,则返回>0,
  否则返回-1,而且errno被置为11(#defineEWOULDBLOCK EAGAIN /* Operation would block */)。
  
因此对于简单的状态判断(不过多考虑异常情况),
recv返回>0, 正常
返回-1,而且errno被置为11 正常
其它情况 关闭

不过当然了,因为我们使用了epoll,所以【返回-1,而且errno被置为11】的情况不会被epoll反馈出来,所以我们只需要判断接收到的字节数是否大于0就可以了~ 是,就保持描述符连接;否则,就关闭这个描述符。

模型的源代码

好了这个模型目前用到的基本知识点基本说完了,下面把代码放上来。
因为涉及到需要的头文件集被包含在另一个头文件里,所以如果需要了解有哪些头文件的话可以到我Github里的EC_Demo项目查EC_include.h文件。
附上链接:Github/xusongqi/EC_Demo
代码:
/* 
* Author:		[email protected]
* 
* Created Time: 2014年05月13日 星期二 09时59分55秒
* 
* FileName:     server.h
* 
* Description:  
*
*/
#ifndef _SERVER_H_
#define _SERVER_H_

#include "EC_include.h"

#define	LENGTH		1024
#define	MAXEVENTS	1024

char *	server_time();					//返回服务器的本地时间
int		set_non_blocking(int sockfd);	//将传入的描述符设置为非阻塞
int		client_request(int client_fd);		//处理客户请求

char * server_time()
{
	time_t rawtime;//服务器时间
	struct tm * server_time;
	time(&rawtime);
	server_time = localtime(&rawtime);
	return asctime(server_time);
}

int	set_non_blocking(int sockfd)
{
	/* 内层调用fcntl()的F_GETFL获取flag,
	 * 外层fcntl()将获取到的flag设置为O_NONBLOCK非阻塞*/
	if( fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFL, 0) ) == -1)
	{	return -1;}
	return 0;
}

int client_request(int client_fd)
{
	int recbytes;//	计数buffer收到的字节数[read()]
	char buffer[LENGTH];//存储收到的信息[read()]
	char server_msg[LENGTH];//发送内容长度[write()]
	struct sockaddr_in client_addr;//客户端地址[包括ip与端口]

	/*获取对端ip与端口信息*/
	int len=sizeof(client_addr);
	getpeername( client_fd, (struct sockaddr *)&client_addr,&len );
	/*read() 收取数据*/
	recbytes = read(client_fd, buffer, LENGTH);

	/* 当recbytes > 0,正常
	 * 当recbytes = -1,且errno = 11,正常
	 * 其他情况:关闭*/
	if(recbytes > 0)//当然还有[recbytes <0 && errno == EAGAIN]的情况,但不会被epoll_wait()返回到get_act_fds
	{
		buffer[recbytes]='\0';
		printf("%s    ",buffer);
		printf("from %#x : %#x : ",
					ntohl(client_addr.sin_addr.s_addr),ntohs(client_addr.sin_port));	
		printf("%s\n",server_time());
		return 0;
	}
	else
	{
		/*输出断开连接的时间*/
		printf(" server disconnected from %#x : %#x : ",
					ntohl(client_addr.sin_addr.s_addr),ntohs(client_addr.sin_port));	
		printf("%s\n",server_time());
		close(client_fd);
		return -1;
	}
}

int tcp_server()
{
	int listen_fd,		//描述符:接受所有连接请求	
		client_fd,		//描述符:处理单独的客户请求
		epoll_fd,		//描述符:epoll
		get_act_fds,	//epoll_wait()返回的事件描述符数量
		count_fds = 0;	//epoll监控描述符计数器
	struct sockaddr_in server_addr,//服务端地址
					   client_addr;//客户端地址
	unsigned short portnum = 21567;//服务器使用端口
	int sin_size;//sockaddr_in的地址长度
	struct epoll_event	event_act,//要监听的描述符的动作
						events[MAXEVENTS];//epoll事件队列

	/*设置监听的端口和IP信息*/
	//bzero(&server_addr, sizeof(struct sockaddr_in));
	memset(&server_addr, sizeof(struct sockaddr_in),'\0');
	server_addr.sin_family=AF_INET;
	server_addr.sin_addr.s_addr=htonl(INADDR_ANY);
	server_addr.sin_port=htons(portnum);

	/*socket() */
	listen_fd = socket(AF_INET, SOCK_STREAM,0);
	setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, NULL, 1);//端口复用,最后两个参数常用opt=1和sizeof(opt)
	if(listen_fd == -1)
	{
		printf("SOCKET FAILED    ");
		printf("%s",server_time());
		return 1;
		//exit(1);
	}
	printf("socket ok... ");

	/*bind() */
	if(-1 == bind(listen_fd,(struct sockaddr *)(&server_addr),sizeof(struct sockaddr)))
	{
		printf("BIND FAILED    ");
		printf("%s",server_time());
		return 1;
		//exit(1);
	}
	printf("bind ok... \n");

	/*listen() */
	if(-1 == listen(listen_fd,5))
	{
		printf("LISTEN FAILED    ");
		printf("%s",server_time());
		return 1;
		//exit(1);
	}
	printf("listen ok...");

	sin_size = sizeof(struct sockaddr_in);

	/*epoll_create() */
	epoll_fd = epoll_create(MAXEVENTS);
	event_act.events = EPOLLIN | EPOLLET;//可读检测 + 边缘触发
	event_act.data.fd = listen_fd;//设置新事件为监听描述符

	/*epoll_ctl() 描述符加入监听队列*/
	if( epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &event_act) < 0 )
	{
		printf("EPOLL_CTL FAILED    ");
		printf("%s",server_time());
		return 1;
		//exit(1);
	}
	count_fds++;


	/*开始循环监听 */
	while(1)
	{
		/*epoll_wait() 最后一个参数-1时,当描述符可读则立即返回*/
		get_act_fds = epoll_wait(epoll_fd, events, count_fds, -1);
		//printf("sdklskdlskdl: %d\n",get_act_fds);
		if(get_act_fds == -1)
		{
			printf("EPOLL_WAIT FAILED    ");
			printf("%s",server_time());
			continue;
		}
		for(int i = 0; i < get_act_fds; i++)
		{
			if(events[i].data.fd == listen_fd)
			{
				/*accept() */
				if(-1 == (client_fd = accept(listen_fd,(struct sockaddr *)(&client_addr),&sin_size)))
				{
					printf("ACCEPT FAILED    ");
					printf("%s",server_time());
					continue;
				}
				/*输出连接客户的ip和端口*/
				printf("accept ok... \nserver start get connect from %#x : %#x\n",
							ntohl(client_addr.sin_addr.s_addr),ntohs(client_addr.sin_port));	
				/*如果当前epoll内描述符队列已满*/
				if(count_fds >= MAXEVENTS)
				{
					printf("TOO MANY CONNECTIONS\n");
					continue;
				}
				/*设置非阻塞io*/
				if( set_non_blocking(client_fd) != 0 )
				{
					printf("SET_NON_BLOCKING FAILED    ");
					printf("%s",server_time());
					close(client_fd);
					continue;
				}
				/*设置epoll对该描述符的监听模式*/
				event_act.events = EPOLLIN | EPOLLET;
				event_act.data.fd = client_fd;
				/*epoll_ctl() 描述符加入监听队列*/
				if( epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event_act) <0 )
				{
					printf("EPOLL_CTL ADD CLIENT FAILED    ");
					printf("%s",server_time());
					close(client_fd);
					continue;
				}
				count_fds++;
				continue;
			}//[if(events[i] == listen_fd)]结束
			/*处理客户请求,若连接断开则从epoll监听队列中删除该描述符*/
			else if(client_request(events[i].data.fd) < 0)
			{
				epoll_ctl(epoll_fd, EPOLL_CTL_DEL, events[i].data.fd, &event_act);
				count_fds--;
			}
		}//[for(i = 0; i < get_act_fds; i++)]结束
	}//[while大循环]结束
	close(listen_fd);
}
#endif


参考资料


浅谈server端的设计模式—szm(主要参考)

Linux IO多路复用之epoll网络编程(含源码) (主要参考)

sockaddr到sockaddr_in的转换以及从中取得IP和端口

从描述符获取IP与端口信息:getpeername()函数 百度百科

设置非阻塞IO为何调用两次fcntl函数

fcntl()   百度百科   

判断socket连接是否已经断开 






你可能感兴趣的:(linux,服务器,epoll,网络编程,非阻塞IO)