技术: 服务端网络模型

summary of server side network programming model again.

上次我在networking io中把服务器从 “阻塞模型” 到 “IO复用”以及 “异步IO” ,
最后到 “Libevent核心实现” 全给说了一遍.

但是上次全是大白话, 没有一点儿代码, 本次就把相关代码补充一下包括多线程/多进程, select等.

但是!! 我不得不说的是, 服务端&后台编码工作, 绝对不像某些新手想的那样容易(也比客户端开发辛苦, 狠多)

server

引子

集中服务器模型以及其编程实现思路, 本文给出的都是核心逻辑代码, 有待进一步完善和相关实际代码佐证.

(本文谈到的许多并发模型, 都有相关demo代码, 可以在本github中找到)

(网络及网络应用程序的编程思想)

在网络程序里面, 一般的来说都是许多客户机对应一个服务器. 为了处理客户机的请求, 对服务端的程序就提出了特殊的要求.

服务端总体分为2大类:

  • 并发服务器: 在同一个时刻可以响应多个客户端的请求
  • 非并发服务器: 在同一个时刻只可以响应一个客户端的请求(过时, 或者应用规模非常小)

正文

非并发

upd类型

UDP 服务器每次从套接字上读取一个客户端的请求, 处理, 然后将结果返回给客户机.

1
2
3
4
5
6
7
8
socket(...);
bind(...); //不需要listen()
while(1)
{
recvfrom(...);
process(...);
sendto(...);
}

因为 UDP 是非面向连接的(不需要Listen),没有一个客户端可以老是占住服务端.
只要处理过程不是死循环, 服务器对于每一个客户机的请求总是能够满足.

tcp类型

就是常见的socket编程的那一套

1
2
3
4
5
6
7
8
9
10
11
12
13
14
 socket(...);
bind(...);
listen(...);
while(1)
{
accept(...);
while(1)
{
read(...);
process(...);
write(...);
}
close(...);
}

TCP服务器一次只能处理一个客户端的请求.只有在这个客户的所有请求都满足后,服务器才可以继续后面的请求.
这样如果有一个客户端占住服务器不放时, 其它的客户机都不能工作了. 因此, TCP 服务器一般很少用该阻塞模型的.
(你把socket fd设置为异步的, 只能是算是一个很小的改进, 因为真正读写的时候还是阻塞的, 只是么有客户端请求时可以立即返回)

并发类型

并发服务器的思想是每一个客户机的请求并不由服务器直接处理, 而是服务器创建一个子进程(线程)来处理, 避免阻塞接收请求的进程/线程.
我把事件IO, 也算在其内了.


TCP类型
多线程(进程)模型;

多进程模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
socket(...);
bind(...);
listen(...);
while(1)
{
accept(...);
pid = fork();
if(pid==0)//子进程中进行处理
{
close(listen_fd); //不要让子进程再去监听
while(1)
{
read(...);
process(...);
write(...);
}
close(...); //close connfd
exit(...);
}else if (pid>0){ //parent close connfd
close(connfd);
}
}//end of while(1)
close(...); //close listenfd

TCP 并发服务器可以解决 TCP 循环服务器客户机独占服务器的情况.

不过也同时带来了一个不小的问题. 为了响应客户机的请求, 服务器要创建子进程来处理, 而创建子进程是一种非常消耗资源的操作.
换句话说, 服务器所能创建的子进程是有限的, 取决于硬件资源.

具体的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#define MY_PORT 8888

int main(int argc ,char **argv)
{
int listen_fd, accept_fd;
struct sockaddr_in server_addr;

int n;
if( (listen_fd=socket(AF_INET,SOCK_STREAM,0)) <0) {
printf("Socket Error:%s\n\a",strerror(errno));
exit(1);
}

bzero(&server_addr,sizeof(struct sockaddr_in));
server_addr.sin_family=AF_INET;
server_addr.sin_port=htons(MY_PORT);
server_addr.sin_addr.s_addr=htonl(INADDR_ANY);

n=1;
/* 如果服务器终止后, 服务器可以第二次快速启动而不用等待一段时间 */
setsockopt(listen_fd,SOL_SOCKET,SO_REUSEADDR,&n,sizeof(int));

if(bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) <0 ) {
printf("Bind Error:%s\n\a",strerror(errno));
exit(-1);
}

listen(listen_fd, 5);

while(1) {
accept_fd = accept(listen_fd,NULL,NULL); //不关心客户端是谁
if( (accept_fd<0) && (errno==EINTR) ) {
continue;
} else if(accept_fd<0) {
printf("Accept Error:%s\n\a",strerror(errno));
continue;
}

/* 子进程处理客户端的连接 */
if( (n=fork()) == 0 ) {
char buffer[1024];

close(listen_fd); //不要让子进程再去监听

n = read(accept_fd, buffer, 1024);
write(accept_fd, buffer, n);
close(accept_fd);
exit(0);
} else if ( n<0 ) {
printf("Fork Error:%s\n\a",strerror(errno));
close(accept_fd);
}else {
close(accept_fd);
}
} //end of while(1)
close(listen_fd)

}//end of main()

多线程模型

和多进程类似, 也是accept之后pthread_create(), 然后在线程的回调函数中进行读写处理.

参考代码:
server.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
/* server.c */
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>

#define MAXLINE 80
#define SERV_PORT 6666

struct s_info {
struct sockaddr_in cliaddr;
int connfd;
};

void *do_work(void *arg)
{
int n,i;
struct s_info *ts = (struct s_info*)arg;
char buf[MAXLINE];
char str[INET_ADDRSTRLEN];

//or u can set the thread attribute
pthread_detach(pthread_self());

while (1) {
n = Read(ts->connfd, buf, MAXLINE);
if (n == 0) {
printf("the other side has been closed.\n");
break;
}
printf("received from %s at PORT %d\n",
inet_ntop(AF_INET, &(*ts).cliaddr.sin_addr, str, sizeof(str)),
ntohs((*ts).cliaddr.sin_port));

for (i = 0; i < n; i++) {
buf[i] = toupper(buf[i]);
}
Write(ts->connfd, buf, n);
}

Close(ts->connfd);
}



int main(void)
{
struct sockaddr_in servaddr, cliaddr;
socklen_t cliaddr_len;
int listenfd, connfd;
int i = 0;
pthread_t tid;
struct s_info ts[256];

listenfd = Socket(AF_INET, SOCK_STREAM, 0);

bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);

Bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr));
Listen(listenfd, 20);

printf("Accepting connections ...\n");

while (1) {
cliaddr_len = sizeof(cliaddr);
connfd = Accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);
//不管是多进程还是多线程, 都是先accept再处理分裂
ts[i].cliaddr = cliaddr;
ts[i].connfd = connfd;
/* 达到线程最大数时, pthread_create 出错处理, 增加服务器稳定性 */
pthread_create(&tid, NULL, do_work, (void*)&ts[i]);
i++;
}

return 0;
}

事件驱动

又称为IO复用, 多路复用I/O.

主要涉及select, pselect, poll, epoll相关的函数(思想上select,poll类似, epoll是2.6之后的改进版本, epoll已经单独说过了).

以select为例

  • 比如说服务器要从缓冲区(用户态进程内存)中读取数据, 但是远端client还没有向内核中发送数据(更不要谈把数据从内核往用户内存拷贝), 那么处理该请求的服务器进程就只有等待咯;
  • 如果外部有一个大管家, 比如select, 它来管理所有的请求, 通知用户进程(即服务器)哪些是可读的(有IO操作的), 可读的时候再来读写(cpu几乎都交给大管家select了, 你可以说程序大部分时间阻塞在select这里)

在我们调用 select 时进程会一直阻塞直到以下的一种情况发生:

  • 有文件可以读
  • 有文件可以写
  • 超时所设置的时间到

下面对函数原型进行了细致讲解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/* According to POSIX.1-2001 */
#include <sys/select.h> /*一般使用这个头文件足够了*/

/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>


/*
函数参数解释如下:

* readfds 所有要读的文件文件描述符的集合
* writefds 所有要的写文件文件描述符的集合
* exceptfds 其他要向我们通知的文件描述符集合(错误输出集合)
* timeout 超时设置.
* nfds 所有我们监控的文件描述符中最大的那一个加 1
*/

int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);

//为了设置文件描述符我们要使用几个宏
void FD_SET(int fd, fd_set *set); //将 fd 加入到 fdset
void FD_CLR(int fd, fd_set *set); //将 fd 从 fdset 里面清除
int FD_ISSET(int fd, fd_set *set); //判断 fd 是否在 fdset 集合中
void FD_ZERO(fd_set *set); //从 fdset 中清除所有的文件描述符


/* 当然还有一个升级版, 多了一个参数 const sigset_t *sigmask*/
/*一看就知道是用来屏蔽信号的咯*/
#include <sys/select.h>
int pselect(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, const struct timespec *timeout,
const sigset_t *sigmask);

使用select的代码大致如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/*param:  int readfd[fd_size] */
int select_read(int *readfd, int fd_size)
{
fd_set my_readfd;
int maxfd, i;

//1. find maxfd
maxfd = readfd[0];
for(i=0; i<fd_size; ++i){
if(readfd[i]>maxfd) {
maxfd = readfd[i];
}
}

while(1) {
/*put all the fd into my_readfd fd_set,
which is a copy of origin readfd set*/
FD_ZERO(&my_readfd);
for(i=0; i<fd_size; ++i) {
FD_SET(readfd[i], &my_readfd);
}

/*now select take charge of all the process*/
select(maxfd+1, &my_readfd, NULL, NULL, NULL);

/*once select return, there must some readfds ok, find which could read*/
for(i=0;i<fd_size;++i){
if(FD_ISSET(readfd[i], &my_readfd)) {
/*read now*/
common_read_code(readfd[i]);
}
}
}//end of while(1)
}//end of func

int readfd[fd_size]通常用来描述已经连接的, 并且之火select返回后还是从该集合中查找活跃的.

此时服务器模型变成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

初始话(socket,bind,listen); //一些列动作
while(1)
{
设置监听读写文件描述符(FD_*);
调用 select;

(遍历监听队列)如果是监听套接字就绪,说明一个新的连接请求建立
{
建立连接(accept);
加入到监听文件描述符中去;
}
否则说明是一个已经连接过的描述符
{
进行操作(read 或者 write);
}
}

(其他的就不在演示了; select/poll, epoll会单独文档仔细说)


UDP类型

和并发的 TCP 服务器模型一样是创建一个子进程来处理的 算法和并发的 TCP 模型一样,
除非服务器在处理客户端的请求所用的时间比较长以外(此时需要单独开一个进程), 人们实际上很少用这种模型.

并且udp类型, 在服务端一般不会调用 connect 绑定对端, 所以本身就可以面向多个客户.
udp一般用于广播, 组播, 其实本身就支持并发操作了.


尾巴

本文对于上次network io一文进行了补充, 给出了服务端应用程序的编码思路.
但是并没有事无巨细的说, 毕竟现在一般公司都有自己的封装或者开源框架.

就这样吧.

文章目录
  1. 1. 引子
  2. 2. 正文
    1. 2.1. 非并发
      1. 2.1.1. upd类型
      2. 2.1.2. tcp类型
    2. 2.2. 并发类型
      1. 2.2.1. 多进程模型
      2. 2.2.2. 多线程模型
      3. 2.2.3. 事件驱动
  3. 3. 尾巴
|