模块三思考题解答

136 阅读7分钟

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

练习题详解

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

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

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

下面是是一段摘自“github.com/millken/c-e…”的示例程序,该程序用 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 问题,不能死记硬背,而是要理解每个细微选择背后的逻辑,并在完成工作后认真对程序进行性能测试。这样才能做到万无一失。