echoserver的几种实现-1(socket, select, epoll)


学习Linux下的网络编程相关内容,最简单直接的方式就是实现一个echoserver,打通client->server->processing->client的通路。最近在研究libuv,所以就分别用纯socket、select、epoll、libuv几种方式实现了各自的echoserver版本。

socket


socket是网络通信的基础,无论是后面将要讲到的select、epoll还是libuv,都是基于socket来实现的。
一般直接使用socket建立网络连接分如下几步:

a. create socket
b. bind socket
c. listen
d. accept&processing

create socket

使用如下语句即可创建socket

1
sockfd = socket(AF_INET, SOCK_STREAM, 0);

第一个参数用于表示通信的范围,AF_INET表示IPv4,其他常用的参数常量还有AF_UNIX(本地通信),AF_INET6(IPv6)等。
第二个参数表示socket的类型,SOCK_STREAM用于表示TCP,还可用SOCK_DGRAM表示UDP。
第三个参数用于表示协议,如果某种类型的socket,其下只有一种协议实现的话,这个参数一般填写为0即可。

bind socket

bind操作用于配置socket监听的地址、端口等信息。

1
2
3
4
5
6
struct sockaddr_in server_addr;
bzero((char *)&server_addr, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(DEFAULT_PORT);
bind_res = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));

这里需要留意的是,bind的第二个参数具体传入的数据类型与socket创建时的通信范围有关。这里的socket是AF_INET,所以使用sockaddr_in;如果是AF_UNIX,则需要使用sockaddr_un。

listen

listen操作使得socket开始真正的监听

1
listen(sockfd, 5);

listen的第二个参数表示backlog,即连接的最大缓存个数,需要根据应用场景具体调节大小。

accept&processing

直接使用socket编写网络程序,为了保证并发,一般会将阻塞式的accept和线程/进程结合起来。这里我选择使用fork新开一个子进程的方式处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct sockaddr_in client_addr;
clilen = sizeof(client_addr);
while(1)
{
newsockfd = accept(sockfd, (struct sockaddr *)&client_addr, &clilen);
if(newsockfd < 0)
error("Error when accept socket.\n");
pid = fork();
if(pid < 0)
error("Error when fork.\n");
if(pid == 0)
{
close(sockfd);
echo_message(newsockfd);
exit(0);
}
else
{
close(newsockfd);
}
}

我们在一个无限循环中,不断地调用阻塞式的accept等待新连接的到来。accept会返回一个新创建的socket,这个newsocket可以用于处理和对应客户端的通信请求。并且,对应客户端的信息会填充好client_addr结构。
每当在循环中接受了一个新的客户端连接后,都调用fork()创建一个新的子进程。在子进程中,复制得到的监听sockfd其实是没有用的,所以可以直接关掉它(同理,在父进程中,也可以直接关掉newsockfd),之后使用echo_message函数处理这个客户端的连接。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void echo_message(int newsockfd)
{

char buffer[256];
int n;
while(1)
{
bzero(buffer, 256);
n = read(newsockfd, buffer, 255);
if(n < 0)
error("Error when reading socket.\n");
if(n == 0)
return;
printf("Here is the message: %s", buffer);
n = write(newsockfd, buffer, strlen(buffer));
if(n < 0)
error("Error when writing socket.\n");
}
}

在linux一切皆是文件,所以对于newsockfd代表的客户端socket来说,可以直接调用read、write来实现回显操作。由于这段代码是在新进程中运行的,所以完全可以不用关心父进程的accept是否阻塞,直接一个while(1)循环搞定。
整体的实现代码详见我的GitHub

select


在上面的实现方式中,进程/线程的生成需要一定的消耗,限制并发数量,并且,多进程/多线程也给编程带来了数据同步、竞争等一系列问题。这时可以通过引入select来解决这一问题。

select是一种用于监视文件事件的方式。在Linux系统中,socket也是一种文件,所以我们可以通过select来判断所监听的socket中有没有事件(新连接、新数据)发生,这样我们就可以把所有的处理(accept, echo-message)都放在同一个线程中进行,而不用担心阻塞操作会影响到其他操作,因为使用了select之后,每次使用accept这种有可能阻塞的函数调用时,都是“有的放矢”的。

使用select的前几步和之前一样,都是create socket,bind socket,listen,只有在accept&processing的时候,需要提前使用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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
while(1)
{
FD_ZERO(&fdsr);
FD_SET(sockfd, &fdsr);
for (i=0; i<BACKLOG; ++i)
{
if (fd_A[i] != 0)
{
FD_SET(fd_A[i], &fdsr);
}
}
ret = select(maxsock + 1, &fdsr, NULL, NULL, &tv);
if (ret < 0)
{
error("Error in select.\n");
}
else if (ret == 0)
{
//printf("Just timeout.\n");
continue;
}
for (i=0; i<conn_amount; ++i)
{
if (FD_ISSET(fd_A[i], &fdsr))
{
echo_message(fd_A[i]);
}
}

if (FD_ISSET(sockfd, &fdsr))
{
newfd = accept(sockfd, (struct sockaddr *)&client_addr, &clilen);
if (newfd < 0)
error("Error when accept socket.\n");
if (conn_amount < BACKLOG)
{
fd_A[conn_amount] = newfd;
conn_amount++;
printf("New connection client[%d] %s:%d\n", conn_amount,
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
if (newfd > maxsock)
{
maxsock = newfd;
}
}
else
{
printf("Now can't accept more connections.\n");
send(newfd, "bye", 4, 0);
close(newfd);
continue;
}
}
}

select的第一个参数需要填写成所需监听的最大文件描述符+1,所以我们使用了maxsock来保存当前监听列表中最大的文件描述符。第2-4个参数分别为监听读事件的描述符列表、监听写事件的描述符列表、监听异常事件的描述符列表。最后一个参数是阻塞的超时时间,如果设置为0,则立即返回,如果传入NULL指针,则无限期阻塞直至有事件发生。

我们在这里首先将绑定在服务端的sockfd通过FD_SET加入至读事件监听集合中。通过FD_ISSET来判断sockfd上是否有读事件发生。如果sockfd上有事件产生,则像之前一样用accept生成一个绑定对应客户端的socket,并加入到fd_A列表中。

在每次循环开始时,都将fd_A列表中保存的描述符通过FD_SET加入至读事件监听集合中。同样的,通过FD_ISSET来判断fd_A中的socket是否有读事件发生。如果有,则通过echo_message处理客户端的数据。

这里的echo_message因为和主循环在同一个线程中,所以不能像之前一样,通过while(1)+阻塞的read调用实现。由于我们将读事件的监听都放在了主循环中,所以在echo_message中只需要读取数据,并写回客户端即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void echo_message(int newsockfd)
{

char buffer[256];
int n;
bzero(buffer, 256);
n = read(newsockfd, buffer, 255);
if(n < 0)
perror("Error when reading socket.\n");
if(n == 0)
return;
printf("Here is the message: %s", buffer);
n = write(newsockfd, buffer, strlen(buffer));
if(n < 0)
perror("Error when writing socket.\n");
}

完整代码详见我的GitHub

epoll


使用select可以在一个线程中实现非阻塞的echoserver。但是select在每次返回之后,需要遍历所有的监听文件描述符,通过FD_ISSET是否有事件发生,这样降低了效率,因为一般来讲读事件只存在于少数的描述符上。另外,select支持的文件描述符的个数有上限限制。最后,select需要把描述符的消息从内核空间拷贝到用户空间,影响效率。

使用epoll可以解决这几个问题。首先,epoll仅仅将有事件发生的文件描述符返回给用户,避免了大量不必要的遍历;第二,epoll对于文件描述符数量的上限的限制是系统所能打开的最大文件数量,上限大大提高;第三,epoll通过mmap加速内核与用户空间的消息传递。

同样的,我们首先需要create socket,bind socket,listen。
使用epoll首先要创建要给epoll的实例出来。

1
epollfd = epoll_create(MAX_EVENTS);

传入的参数在Linux2.6.8之后已经被忽略了,只要大于0即可。
然后,将绑定在服务端的socket加入到epoll实例中。
1
2
3
4
5
6
7
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sockfd;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, sockfd, &ev) == -1)
{
error("Error when add listen sock to epoll.\n");
}

在主循环中,通过epoll_wait等待事件发生。
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
while(1)
{
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1)
{
error("Error when epoll wait.\n");
}
for (i=0; i<nfds; ++i)
{
if (events[i].data.fd == sockfd)
{
newsock = accept(sockfd, (struct sockaddr *)&client_addr, &clilen);
if (newsock == -1)
{
error("Error when accept new sock.\n");
}

//setnonblocking(newsock);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = newsock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, newsock, &ev) == -1)
{
error("Error when add newsock to epoll.\n");
}
}
else
{
echo_message(events[i].data.fd);
}
}
}

epoll_wait会将有事件发生的文件描述符打包在返回值中返回。
如果sockfd上有事件发生,那么通过accept生成绑定客户端的描述符。并将其加入到epoll实例中进行监听。

如果事件不是发生sockfd上,那么说明有客户端的输入到来,则调用echo_message进行回显。这里的echo_message函数和select中的完全相同,不再列出。
完整代码详见我的GitHub


至此,我们使用socket select epoll三种方式实现了echoserver,并对各个方式的优劣均有了一定的了解。接下来,我会新开一片博客,说说如何使用libuv实现echoserver。

P.S. 一个相应的客户端的实现,详见GitHub

转载请注明出处: http://blog.guoyb.com/2016/05/22/echo-server/

欢迎关注我的微信公众号TechTalking,技术·生活·思考:
后端技术小黑屋

评论