大师兄

加餐 | 模块三思考题解答

今天我会带你把《模块三:网络编程》中涉及的课后练习题,逐一讲解,并给出每个课时练习题的解题思路和答案。

练习题详解

10 | Socket 编程:epoll 为什么用红黑树?

问题请你找一个 epoll 的 hello world 例子,并尝试理解它

解析】epoll 是一个 C 语言的 API,因此使用的时候需要一点 C 的基础。不过,即便没有,其实也不影响你读懂下面的程序。

下面是是一段摘自“https://github.com/millken/c-example/blob/master/epoll-example.c”的示例程序,该程序用 epoll 模式实现了一个服务,如下所示:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <errno.h>
#define MAXEVENTS 64
static int
make_socket_non_blocking (int sfd)
{
int flags, s;
flags = fcntl (sfd, F_GETFL, 0);
if (flags == -1)
{
perror ("fcntl");
return -1;
}
flags |= O_NONBLOCK;
s = fcntl (sfd, F_SETFL, flags);
if (s == -1)
{
perror ("fcntl");
return -1;
}
return 0;
}
static int
create_and_bind (char *port)
{
struct addrinfo hints;
struct addrinfo *result, *rp;
int s, sfd;
memset (&hints, 0, sizeof (struct addrinfo));
hints.ai_family = AF_UNSPEC; /* Return IPv4 and IPv6 choices */
hints.ai_socktype = SOCK_STREAM; /* We want a TCP socket */
hints.ai_flags = AI_PASSIVE; /* All interfaces */
s = getaddrinfo (NULL, port, &hints, &result);
if (s != 0)
{
fprintf (stderr, "getaddrinfo: %s\n", gai_strerror (s));
return -1;
}
for (rp = result; rp != NULL; rp = rp->ai_next)
{
sfd = socket (rp->ai_family, rp->ai_socktype, rp->ai_protocol);
if (sfd == -1)
continue;
s = bind (sfd, rp->ai_addr, rp->ai_addrlen);
if (s == 0)
{
/* We managed to bind successfully! */
break;
}
close (sfd);
}
if (rp == NULL)
{
fprintf (stderr, "Could not bind\n");
return -1;
}
freeaddrinfo (result);
return sfd;
}
int
main (int argc, char *argv[])
{
int sfd, s;
int efd;
struct epoll_event event;
struct epoll_event *events;
if (argc != 2)
{
fprintf (stderr, "Usage: %s [port]\n", argv[0]);
exit (EXIT_FAILURE);
}
sfd = create_and_bind (argv[1]);
if (sfd == -1)
abort ();
s = make_socket_non_blocking (sfd);
if (s == -1)
abort ();
s = listen (sfd, SOMAXCONN);
if (s == -1)
{
perror ("listen");
abort ();
}
efd = epoll_create1 (0);
if (efd == -1)
{
perror ("epoll_create");
abort ();
}
event.data.fd = sfd;
event.events = EPOLLIN | EPOLLET;
s = epoll_ctl (efd, EPOLL_CTL_ADD, sfd, &event);
if (s == -1)
{
perror ("epoll_ctl");
abort ();
}
/* Buffer where events are returned */
events = calloc (MAXEVENTS, sizeof event);
/* The event loop */
while (1)
{
int n, i;
n = epoll_wait (efd, events, MAXEVENTS, -1);
for (i = 0; i < n; i++)
{
if ((events[i].events & EPOLLERR) ||
(events[i].events & EPOLLHUP) ||
(!(events[i].events & EPOLLIN)))
{
/* An error has occured on this fd, or the socket is not
ready for reading (why were we notified then?) */
fprintf (stderr, "epoll error\n");
close (events[i].data.fd);
continue;
}
else if (sfd == events[i].data.fd)
{
/* We have a notification on the listening socket, which
means one or more incoming connections. */
while (1)
{
struct sockaddr in_addr;
socklen_t in_len;
int infd;
char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV];
in_len = sizeof in_addr;
infd = accept (sfd, &in_addr, &in_len);
if (infd == -1)
{
if ((errno == EAGAIN) ||
(errno == EWOULDBLOCK))
{
/* We have processed all incoming
connections. */
break;
}
else
{
perror ("accept");
break;
}
}
s = getnameinfo (&in_addr, in_len,
hbuf, sizeof hbuf,
sbuf, sizeof sbuf,
NI_NUMERICHOST | NI_NUMERICSERV);
if (s == 0)
{
printf("Accepted connection on descriptor %d "
"(host=%s, port=%s)\n", infd, hbuf, sbuf);
}
/* Make the incoming socket non-blocking and add it to the
list of fds to monitor. */
s = make_socket_non_blocking (infd);
if (s == -1)
abort ();
event.data.fd = infd;
event.events = EPOLLIN | EPOLLET;
s = epoll_ctl (efd, EPOLL_CTL_ADD, infd, &event);
if (s == -1)
{
perror ("epoll_ctl");
abort ();
}
}
continue;
}
else
{
/* We have data on the fd waiting to be read. Read and
display it. We must read whatever data is available
completely, as we are running in edge-triggered mode
and won't get a notification again for the same
data. */
int done = 0;
while (1)
{
ssize_t count;
char buf[512];
count = read (events[i].data.fd, buf, sizeof buf);
if (count == -1)
{
/* If errno == EAGAIN, that means we have read all
data. So go back to the main loop. */
if (errno != EAGAIN)
{
perror ("read");
done = 1;
}
break;
}
else if (count == 0)
{
/* End of file. The remote has closed the
connection. */
done = 1;
break;
}
/* Write the buffer to standard output */
s = write (1, buf, count);
if (s == -1)
{
perror ("write");
abort ();
}
}
if (done)
{
printf ("Closed connection on descriptor %d\n",
events[i].data.fd);
/* Closing the descriptor will make epoll remove it
from the set of descriptors which are monitored. */
close (events[i].data.fd);
}
}
}
}
free (events);
close (sfd);
return EXIT_SUCCESS;
}

接下来我给你分析下这段程序。下面这句在创建一个 epoll 实例,这个实例本质上也是一个文件,文件中是对epoll对象的调用序列。

efd = epoll_create1 (0);

下面这段程序在注册线程关心的事件:

struct epoll_event event;
event.data.fd = sfd;
event.events = EPOLLIN | EPOLLET;
s = epoll_ctl (efd, EPOLL_CTL_ADD, sfd, &event);

上面程序注册了两类关系的事件:

  • EPOLLIN ,关联的文件发生的读取;

  • EPOLLET, 关联的文件发生的写入。

接下来我们调用epoll_wait来获取发生的事件:

n = epoll_wait (efd, events, MAXEVENTS, -1)

n是需要响应的事件数量。 因为在这之前用make_socket_non_blocking配置了非阻塞 IO,因此epoll_wait有可能返回 0,也就是没有消息。 对于n>0的情况,上面的示例程序中使用了 for 循环针对不同的消息类型进行处理。

下面这句if判断是在看如果 sfd(服务端 Socket 文件描述符)和发生事件的文件描述符一致,代表这是一次客户端的连接操作。

if (sfd == events[i].data.fd)

于是再次调用epoll_ctl将这个客户端的读写事件注册到关注列表。

如果上面的if判断没有生效,说明这是一次客户端的读或写,这个时候使用readwrite方法向客户端 Socket 文件中读取/写入数据。

11 | 流和缓冲区:缓冲区的 flip 是怎么回事?

问题】在缓冲区的设计当中,还通常有一个 rewind 操作,这个操作是用来做什么的呢?

解析】之前我们讨论了如果一个缓冲区是用来写入的,接下来要切换到读取状态可以使用 flip 操作。如果一个缓冲区进行了一次写和读,接下来要用它来处理另一批数据,可以使用 clear 操作来清空缓冲区。在实战当中,有时候一个缓冲区读取过了,需要再读取一次,此时就可以用 rewind 操作来重置缓冲区的 position 指针。

上面过程中 flip 和 rewind 都重置了 position 指针,那么它们的区别是什么呢?首先,你可以先从词义上理解下,flip 意味翻转(隐含读写状态切换),rewind 意味倒带(隐含重头读、重头写)。所以在实战中,首先我们应该从语义上区分它们的使用。

在实战的过程中,某些场景下 rewind 和 flip 结果相同。

比如现在缓冲区是 ABCDEFG,position=7, limit=7。这个时候代表我们已经完成了写入。如果需要切换到读取状态,用 flip 和 rewind 操作的结果相同,都会将 position 置零。

那么我提一个问题,这种情况下,应该用哪个呢?

写程序不只是为了正确,我们还为了可读。这种情况下,因为是读写状态的切换,因此当然用 flip。

再举个例子,比如现在缓冲区是 ABCDEFG,position=3,limit=7,缓冲区处于读取状态。如果我们想要重读,应该用什么呢?当然是 rewind,rewind 有倒带的语义。你可以思考,这个时候如果调 flip 结果对吗?

这个时候调 flip 处理会把 position 置为 0 外,limit 也会设置为 3(position 的旧值)。因为只有这样,才是读写状态的翻转。也就是说,如果写入了 3 个字符,不管 limit 现在是多少,flip 切换到读取状态也只能读 3 个字符。

所以,flip 和 rewind 实现不同是其次,最重要的是语义不同。建议你以后看到 API 的时候,先搞明白单词是什么意思,而不是急于分析具体实现。从这个话题引申出一个小的提示,就是不要盲目读源代码,在阅读一个项目的源代码前,思考下自己对要解决的问题、如何解决这些问题,带着这种根深的理解再去读源码。

12 | 网络 I/O 模型:BIO、NIO 和 AIO 有什么区别?

问题】I/O 多路复用用协程和用线程的区别?

解析】线程是执行程序的最小单位。I/O 多路复用时,会用单个线程处理大量的 I/O。还有一种执行程序的模型,叫协作程,协程是轻量级的线程。操作系统将执行资源分配给了线程,然后再调度线程运行。如果要实现协程,就要利用分配给线程的执行资源,在这之上再创建更小的执行单位。协程不归操作系统调度,协程共享线程的执行资源。

而 I/O 多路复用的意义,是减少线程间的切换成本。因此从设计上,只要是用单个线程处理大量 I/O 工作,线程和协程是一样的,并无区别。如果是单线程处理大量 I/O,使用协程也是依托协程对应线程执行能力。

13 | 面试中如何回答“怎样实现 RPC 框架”的问题?

问题】如何理解 Dubbo 的几个组成部分 Consumer、Provider、Monitor 和 Registry?

解析】Dubbo 是一个开源、轻量级的 Java 服务框架。下图是它的架构:

image (2).png

Dubbo 的架构是容器化的,上 图中的 Container(容器)中是服务,服务的提供方被称作 Provider。比如要提供一个订单服务,那么服务会在容器中部署启动,启动后的实例就是 Provider。

Provider 在启动过程中,会在 Dubbo 中注册自己。负责注册和发现的模块,称为注册处(Registry)。注册处和学员报道时学校的注册处很像,每个新加入的服务都需要主动注册。这里需要注意,注册处对网络中的信息是信任的,如果 Provider 被攻击欺骗注册处会产生安全问题。Registry 需要实现分布式共识,具体可以使用 ZooKeeper实现(参考 Paxos 和 Raft 算法)

服务的使用方被称为 Consumer,Consumer 会订阅注册表的变化(也就是 Provider 的变化)。相当于 Consumer 本地维护了一份和注册处一致的 Provider 清单。当调用服务的时候,Consumer 会使用本地清单去查询 Provider 信息,进行远程调用。

除了 Registry、Consumer、Provider 之外,Dubbo 还有一个 Monitor 模块。这个模块负责统计服务器的调用情况。

总结

《网络编程》模块我们围绕着Socket展开,Socket 是程序也是文件。文件本质是数据,为了抽象数据,我们学习了。这里再复习下,流是随着时间产生的数据。文件传输、视频播放、在线游戏……这些都是随着时间产生的数据。为了提升处理数据的效率,节省内存资源,我们还学习了缓冲区。关于缓冲区,目前向你介绍了 3 种操作:flip 用于读写切换、clear 用于重置缓冲区、rewind 用于重读数据。

为了减少线程的切换成本,我们会使用 I/O 的多路复用。为了让程序更可读,我们会选择适合的编程模型。这个模块介绍了 3 种编程模型,分别是 BIO/NIO/AIO。选择编程模型处理 I/O 还要思考数据拷贝的效率、事件通知的方式。思考事件通知的方式,又需要思考核心部分数据结构的设计。所以,如果你想在工作当中应对不同场景处理好 I/O 问题,不能死记硬背,而是要理解每个细微选择背后的逻辑,并在完成工作后认真对程序进行性能测试。这样才能做到万无一失。

发现求知的乐趣,我是林䭽,感谢你学习本次课程。 接下来我们将进入《模块四:Web 技术》的学习,下一讲介绍《14 | DNS 域名解析系统:CNAME 记录的作用是?》,再见!