大愚Talk

大愚Talk 查看完整档案

北京编辑成都理工大学  |  计算机科学与技术 编辑LongLongAgo  |  Golang-PHP 编辑 dayutalk.cn 编辑
编辑

Life is short, code more!

个人动态

大愚Talk 收藏了文章 · 9月8日

Linux IO模式及 select、poll、epoll详解

注:本文是对众多博客的学习和总结,可能存在理解错误。请带着怀疑的眼光,同时如果有错误希望能指出。

同步IO和异步IO,阻塞IO和非阻塞IO分别是什么,到底有什么区别?不同的人在不同的上下文下给出的答案是不同的。所以先限定一下本文的上下文。

本文讨论的背景是Linux环境下的network IO。

一 概念说明

在进行解释之前,首先要说明几个概念:
- 用户空间和内核空间
- 进程切换
- 进程的阻塞
- 文件描述符
- 缓存 I/O

用户空间与内核空间

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
1. 保存处理机上下文,包括程序计数器和其他寄存器。
2. 更新PCB信息。
3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
4. 选择另一个进程执行,并更新其PCB。
5. 更新内存管理的数据结构。
6. 恢复处理机上下文。

注:总而言之就是很耗资源,具体的可以参考这篇文章:进程切换

进程的阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的

文件描述符fd

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

缓存 I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

缓存 I/O 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

二 IO模式

刚才说了,对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
1. 等待数据准备 (Waiting for the data to be ready)
2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

正式因为这两个阶段,linux系统产生了下面五种网络模式的方案。
- 阻塞 I/O(blocking IO)
- 非阻塞 I/O(nonblocking IO)
- I/O 多路复用( IO multiplexing)
- 信号驱动 I/O( signal driven IO)
- 异步 I/O(asynchronous IO)

注:由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO Model。

阻塞 I/O(blocking IO)

在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:
clipboard.png

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

所以,blocking IO的特点就是在IO执行的两个阶段都被block了。

非阻塞 I/O(nonblocking IO)

linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
clipboard.png

当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。

I/O 多路复用( IO multiplexing)

IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。

clipboard.png

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。

这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。

所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

异步 I/O(asynchronous IO)

inux下的asynchronous IO其实用得很少。先看一下它的流程:
clipboard.png

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

总结

blocking和non-blocking的区别

调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。

synchronous IO和asynchronous IO的区别

在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。POSIX的定义是这样子的:
- A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
- An asynchronous I/O operation does not cause the requesting process to be blocked;

两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。

有人会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。

而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

各个IO Model的比较如图所示:
clipboard.png

通过上面的图片,可以发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

三 I/O 多路复用之select、poll、epoll详解

select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。(这里啰嗦下)

select

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

select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述副就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。

select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一 个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。

poll

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。

struct pollfd {
    int fd; /* file descriptor */
    short events; /* requested events to watch */
    short revents; /* returned events witnessed */
};

pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。

从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

epoll

epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

一 epoll操作过程

epoll操作过程需要三个接口,分别如下:

int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

1. int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议
当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函数是对指定描述符fd执行op操作。
- epfd:是epoll_create()的返回值。
- op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
- fd:是需要监听的fd(文件描述符)
- epoll_event:是告诉内核需要监听什么事,struct epoll_event结构如下:

struct epoll_event {
  __uint32_t events;  /* Epoll events */
  epoll_data_t data;  /* User data variable */
};

//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待epfd上的io事件,最多返回maxevents个事件。
参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

二 工作模式

 epoll对文件描述符的操作有两种模式:LT(level trigger)ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:
  LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
  ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

1. LT模式

LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。

2. ET模式

ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

3. 总结

假如有这样一个例子:
1. 我们已经把一个用来从管道中读取数据的文件句柄(RFD)添加到epoll描述符
2. 这个时候从管道的另一端被写入了2KB的数据
3. 调用epoll_wait(2),并且它会返回RFD,说明它已经准备好读取操作
4. 然后我们读取了1KB的数据
5. 调用epoll_wait(2)......

LT模式:
如果是LT模式,那么在第5步调用epoll_wait(2)之后,仍然能受到通知。

ET模式:
如果我们在第1步将RFD添加到epoll描述符的时候使用了EPOLLET标志,那么在第5步调用epoll_wait(2)之后将有可能会挂起,因为剩余的数据还存在于文件的输入缓冲区内,而且数据发出端还在等待一个针对已经发出数据的反馈信息。只有在监视的文件句柄上发生了某个事件的时候 ET 工作模式才会汇报事件。因此在第5步的时候,调用者可能会放弃等待仍在存在于文件输入缓冲区内的剩余数据。

当使用epoll的ET模型来工作时,当产生了一个EPOLLIN事件后,
读数据的时候需要考虑的是当recv()返回的大小如果等于请求的大小,那么很有可能是缓冲区还有数据未读完,也意味着该次事件还没有处理完,所以还需要再次读取:

while(rs){
  buflen = recv(activeevents[i].data.fd, buf, sizeof(buf), 0);
  if(buflen < 0){
    // 由于是非阻塞的模式,所以当errno为EAGAIN时,表示当前缓冲区已无数据可读
    // 在这里就当作是该次事件已处理处.
    if(errno == EAGAIN){
        break;
    }
    else{
        return;
    }
  }
  else if(buflen == 0){
     // 这里表示对端的socket已正常关闭.
  }

 if(buflen == sizeof(buf){
      rs = 1;   // 需要再次读取
 }
 else{
      rs = 0;
 }
}

Linux中的EAGAIN含义

Linux环境下开发经常会碰到很多错误(设置errno),其中EAGAIN是其中比较常见的一个错误(比如用在非阻塞操作中)。
从字面上来看,是提示再试一次。这个错误经常出现在当应用程序进行一些非阻塞(non-blocking)操作(对文件或socket)的时候。

例如,以 O_NONBLOCK的标志打开文件/socket/FIFO,如果你连续做read操作而没有数据可读。此时程序不会阻塞起来等待数据准备就绪返回,read函数会返回一个错误EAGAIN,提示你的应用程序现在没有数据可读请稍后再试。
又例如,当一个系统调用(比如fork)因为没有足够的资源(比如虚拟内存)而执行失败,返回EAGAIN提示其再调用一次(也许下次就能成功)。

三 代码演示

下面是一段不完整的代码且格式不对,意在表述上面的过程,去掉了一些模板代码。

#define IPADDRESS   "127.0.0.1"
#define PORT        8787
#define MAXSIZE     1024
#define LISTENQ     5
#define FDSIZE      1000
#define EPOLLEVENTS 100

listenfd = socket_bind(IPADDRESS,PORT);

struct epoll_event events[EPOLLEVENTS];

//创建一个描述符
epollfd = epoll_create(FDSIZE);

//添加监听描述符事件
add_event(epollfd,listenfd,EPOLLIN);

//循环等待
for ( ; ; ){
    //该函数返回已经准备好的描述符事件数目
    ret = epoll_wait(epollfd,events,EPOLLEVENTS,-1);
    //处理接收到的连接
    handle_events(epollfd,events,ret,listenfd,buf);
}

//事件处理函数
static void handle_events(int epollfd,struct epoll_event *events,int num,int listenfd,char *buf)
{
     int i;
     int fd;
     //进行遍历;这里只要遍历已经准备好的io事件。num并不是当初epoll_create时的FDSIZE。
     for (i = 0;i < num;i++)
     {
         fd = events[i].data.fd;
        //根据描述符的类型和事件类型进行处理
         if ((fd == listenfd) &&(events[i].events & EPOLLIN))
            handle_accpet(epollfd,listenfd);
         else if (events[i].events & EPOLLIN)
            do_read(epollfd,fd,buf);
         else if (events[i].events & EPOLLOUT)
            do_write(epollfd,fd,buf);
     }
}

//添加事件
static void add_event(int epollfd,int fd,int state){
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&ev);
}

//处理接收到的连接
static void handle_accpet(int epollfd,int listenfd){
     int clifd;     
     struct sockaddr_in cliaddr;     
     socklen_t  cliaddrlen;     
     clifd = accept(listenfd,(struct sockaddr*)&cliaddr,&cliaddrlen);     
     if (clifd == -1)         
     perror("accpet error:");     
     else {         
         printf("accept a new client: %s:%d\n",inet_ntoa(cliaddr.sin_addr),cliaddr.sin_port);                       //添加一个客户描述符和事件         
         add_event(epollfd,clifd,EPOLLIN);     
     } 
}

//读处理
static void do_read(int epollfd,int fd,char *buf){
    int nread;
    nread = read(fd,buf,MAXSIZE);
    if (nread == -1)     {         
        perror("read error:");         
        close(fd); //记住close fd        
        delete_event(epollfd,fd,EPOLLIN); //删除监听 
    }
    else if (nread == 0)     {         
        fprintf(stderr,"client close.\n");
        close(fd); //记住close fd       
        delete_event(epollfd,fd,EPOLLIN); //删除监听 
    }     
    else {         
        printf("read message is : %s",buf);        
        //修改描述符对应的事件,由读改为写         
        modify_event(epollfd,fd,EPOLLOUT);     
    } 
}

//写处理
static void do_write(int epollfd,int fd,char *buf) {     
    int nwrite;     
    nwrite = write(fd,buf,strlen(buf));     
    if (nwrite == -1){         
        perror("write error:");        
        close(fd);   //记住close fd       
        delete_event(epollfd,fd,EPOLLOUT);  //删除监听    
    }else{
        modify_event(epollfd,fd,EPOLLIN); 
    }    
    memset(buf,0,MAXSIZE); 
}

//删除事件
static void delete_event(int epollfd,int fd,int state) {
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_DEL,fd,&ev);
}

//修改事件
static void modify_event(int epollfd,int fd,int state){     
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_MOD,fd,&ev);
}

//注:另外一端我就省了

四 epoll总结

在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)

epoll的优点主要是一下几个方面:
1. 监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。select的最大缺点就是进程打开的fd是有数量限制的。这对 于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache就是这样实现的),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。

  1. IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。

如果没有大量的idle -connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle- connection,就会发现epoll的效率大大高于select/poll。

参考

用户空间与内核空间,进程上下文与中断上下文[总结]
进程切换
维基百科-文件描述符
Linux 中直接 I/O 机制的介绍
IO - 同步,异步,阻塞,非阻塞 (亡羊补牢篇)
Linux中select poll和epoll的区别
IO多路复用之select总结
IO多路复用之poll总结
IO多路复用之epoll总结

查看原文

大愚Talk 赞了文章 · 9月8日

Linux IO模式及 select、poll、epoll详解

注:本文是对众多博客的学习和总结,可能存在理解错误。请带着怀疑的眼光,同时如果有错误希望能指出。

同步IO和异步IO,阻塞IO和非阻塞IO分别是什么,到底有什么区别?不同的人在不同的上下文下给出的答案是不同的。所以先限定一下本文的上下文。

本文讨论的背景是Linux环境下的network IO。

一 概念说明

在进行解释之前,首先要说明几个概念:
- 用户空间和内核空间
- 进程切换
- 进程的阻塞
- 文件描述符
- 缓存 I/O

用户空间与内核空间

现在操作系统都是采用虚拟存储器,那么对32位操作系统而言,它的寻址空间(虚拟存储空间)为4G(2的32次方)。操作系统的核心是内核,独立于普通的应用程序,可以访问受保护的内存空间,也有访问底层硬件设备的所有权限。为了保证用户进程不能直接操作内核(kernel),保证内核的安全,操心系统将虚拟空间划分为两部分,一部分为内核空间,一部分为用户空间。针对linux操作系统而言,将最高的1G字节(从虚拟地址0xC0000000到0xFFFFFFFF),供内核使用,称为内核空间,而将较低的3G字节(从虚拟地址0x00000000到0xBFFFFFFF),供各个进程使用,称为用户空间。

进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:
1. 保存处理机上下文,包括程序计数器和其他寄存器。
2. 更新PCB信息。
3. 把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。
4. 选择另一个进程执行,并更新其PCB。
5. 更新内存管理的数据结构。
6. 恢复处理机上下文。

注:总而言之就是很耗资源,具体的可以参考这篇文章:进程切换

进程的阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的

文件描述符fd

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。

文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

缓存 I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。

缓存 I/O 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

二 IO模式

刚才说了,对于一次IO访问(以read举例),数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。所以说,当一个read操作发生时,它会经历两个阶段:
1. 等待数据准备 (Waiting for the data to be ready)
2. 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

正式因为这两个阶段,linux系统产生了下面五种网络模式的方案。
- 阻塞 I/O(blocking IO)
- 非阻塞 I/O(nonblocking IO)
- I/O 多路复用( IO multiplexing)
- 信号驱动 I/O( signal driven IO)
- 异步 I/O(asynchronous IO)

注:由于signal driven IO在实际中并不常用,所以我这只提及剩下的四种IO Model。

阻塞 I/O(blocking IO)

在linux中,默认情况下所有的socket都是blocking,一个典型的读操作流程大概是这样:
clipboard.png

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据(对于网络IO来说,很多时候数据在一开始还没有到达。比如,还没有收到一个完整的UDP包。这个时候kernel就要等待足够的数据到来)。这个过程需要等待,也就是说数据被拷贝到操作系统内核的缓冲区中是需要一个过程的。而在用户进程这边,整个进程会被阻塞(当然,是进程自己选择的阻塞)。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。

所以,blocking IO的特点就是在IO执行的两个阶段都被block了。

非阻塞 I/O(nonblocking IO)

linux下,可以通过设置socket使其变为non-blocking。当对一个non-blocking socket执行读操作时,流程是这个样子:
clipboard.png

当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。

所以,nonblocking IO的特点是用户进程需要不断的主动询问kernel数据好了没有。

I/O 多路复用( IO multiplexing)

IO multiplexing就是我们说的select,poll,epoll,有些地方也称这种IO方式为event driven IO。select/epoll的好处就在于单个process就可以同时处理多个网络连接的IO。它的基本原理就是select,poll,epoll这个function会不断的轮询所负责的所有socket,当某个socket有数据到达了,就通知用户进程。

clipboard.png

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。

所以,I/O 多路复用的特点是通过一种机制一个进程能同时等待多个文件描述符,而这些文件描述符(套接字描述符)其中的任意一个进入读就绪状态,select()函数就可以返回。

这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。但是,用select的优势在于它可以同时处理多个connection。

所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)

在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

异步 I/O(asynchronous IO)

inux下的asynchronous IO其实用得很少。先看一下它的流程:
clipboard.png

用户进程发起read操作之后,立刻就可以开始去做其它的事。而另一方面,从kernel的角度,当它受到一个asynchronous read之后,首先它会立刻返回,所以不会对用户进程产生任何block。然后,kernel会等待数据准备完成,然后将数据拷贝到用户内存,当这一切都完成之后,kernel会给用户进程发送一个signal,告诉它read操作完成了。

总结

blocking和non-blocking的区别

调用blocking IO会一直block住对应的进程直到操作完成,而non-blocking IO在kernel还准备数据的情况下会立刻返回。

synchronous IO和asynchronous IO的区别

在说明synchronous IO和asynchronous IO的区别之前,需要先给出两者的定义。POSIX的定义是这样子的:
- A synchronous I/O operation causes the requesting process to be blocked until that I/O operation completes;
- An asynchronous I/O operation does not cause the requesting process to be blocked;

两者的区别就在于synchronous IO做”IO operation”的时候会将process阻塞。按照这个定义,之前所述的blocking IO,non-blocking IO,IO multiplexing都属于synchronous IO。

有人会说,non-blocking IO并没有被block啊。这里有个非常“狡猾”的地方,定义中所指的”IO operation”是指真实的IO操作,就是例子中的recvfrom这个system call。non-blocking IO在执行recvfrom这个system call的时候,如果kernel的数据没有准备好,这时候不会block进程。但是,当kernel中数据准备好的时候,recvfrom会将数据从kernel拷贝到用户内存中,这个时候进程是被block了,在这段时间内,进程是被block的。

而asynchronous IO则不一样,当进程发起IO 操作之后,就直接返回再也不理睬了,直到kernel发送一个信号,告诉进程说IO完成。在这整个过程中,进程完全没有被block。

各个IO Model的比较如图所示:
clipboard.png

通过上面的图片,可以发现non-blocking IO和asynchronous IO的区别还是很明显的。在non-blocking IO中,虽然进程大部分时间都不会被block,但是它仍然要求进程去主动的check,并且当数据准备完成以后,也需要进程主动的再次调用recvfrom来将数据拷贝到用户内存。而asynchronous IO则完全不同。它就像是用户进程将整个IO操作交给了他人(kernel)完成,然后他人做完后发信号通知。在此期间,用户进程不需要去检查IO操作的状态,也不需要主动的去拷贝数据。

三 I/O 多路复用之select、poll、epoll详解

select,poll,epoll都是IO多路复用的机制。I/O多路复用就是通过一种机制,一个进程可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。但select,poll,epoll本质上都是同步I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步I/O则无需自己负责进行读写,异步I/O的实现会负责把数据从内核拷贝到用户空间。(这里啰嗦下)

select

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

select 函数监视的文件描述符分3类,分别是writefds、readfds、和exceptfds。调用后select函数会阻塞,直到有描述副就绪(有数据 可读、可写、或者有except),或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回。当select函数返回后,可以 通过遍历fdset,来找到就绪的描述符。

select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点。select的一 个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,可以通过修改宏定义甚至重新编译内核的方式提升这一限制,但 是这样也会造成效率的降低。

poll

int poll (struct pollfd *fds, unsigned int nfds, int timeout);

不同与select使用三个位图来表示三个fdset的方式,poll使用一个 pollfd的指针实现。

struct pollfd {
    int fd; /* file descriptor */
    short events; /* requested events to watch */
    short revents; /* returned events witnessed */
};

pollfd结构包含了要监视的event和发生的event,不再使用select“参数-值”传递的方式。同时,pollfd并没有最大数量限制(但是数量过大后性能也是会下降)。 和select函数一样,poll返回后,需要轮询pollfd来获取就绪的描述符。

从上面看,select和poll都需要在返回后,通过遍历文件描述符来获取已经就绪的socket。事实上,同时连接的大量客户端在一时刻可能只有很少的处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

epoll

epoll是在2.6内核中提出的,是之前的select和poll的增强版本。相对于select和poll来说,epoll更加灵活,没有描述符限制。epoll使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的copy只需一次。

一 epoll操作过程

epoll操作过程需要三个接口,分别如下:

int epoll_create(int size);//创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

1. int epoll_create(int size);
创建一个epoll的句柄,size用来告诉内核这个监听的数目一共有多大,这个参数不同于select()中的第一个参数,给出最大监听的fd+1的值,参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议
当创建好epoll句柄后,它就会占用一个fd值,在linux下如果查看/proc/进程id/fd/,是能够看到这个fd的,所以在使用完epoll后,必须调用close()关闭,否则可能导致fd被耗尽。

2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
函数是对指定描述符fd执行op操作。
- epfd:是epoll_create()的返回值。
- op:表示op操作,用三个宏来表示:添加EPOLL_CTL_ADD,删除EPOLL_CTL_DEL,修改EPOLL_CTL_MOD。分别添加、删除和修改对fd的监听事件。
- fd:是需要监听的fd(文件描述符)
- epoll_event:是告诉内核需要监听什么事,struct epoll_event结构如下:

struct epoll_event {
  __uint32_t events;  /* Epoll events */
  epoll_data_t data;  /* User data variable */
};

//events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发生错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里

3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待epfd上的io事件,最多返回maxevents个事件。
参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size,参数timeout是超时时间(毫秒,0会立即返回,-1将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回0表示已超时。

二 工作模式

 epoll对文件描述符的操作有两种模式:LT(level trigger)ET(edge trigger)。LT模式是默认模式,LT模式与ET模式的区别如下:
  LT模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用epoll_wait时,会再次响应应用程序并通知此事件。
  ET模式:当epoll_wait检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用epoll_wait时,不会再次响应应用程序并通知此事件。

1. LT模式

LT(level triggered)是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。

2. ET模式

ET(edge-triggered)是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到你做了某些操作导致那个文件描述符不再为就绪状态了(比如,你在发送,接收或者接收请求,或者发送接收的数据少于一定量时导致了一个EWOULDBLOCK 错误)。但是请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once)

ET模式在很大程度上减少了epoll事件被重复触发的次数,因此效率要比LT模式高。epoll工作在ET模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。

3. 总结

假如有这样一个例子:
1. 我们已经把一个用来从管道中读取数据的文件句柄(RFD)添加到epoll描述符
2. 这个时候从管道的另一端被写入了2KB的数据
3. 调用epoll_wait(2),并且它会返回RFD,说明它已经准备好读取操作
4. 然后我们读取了1KB的数据
5. 调用epoll_wait(2)......

LT模式:
如果是LT模式,那么在第5步调用epoll_wait(2)之后,仍然能受到通知。

ET模式:
如果我们在第1步将RFD添加到epoll描述符的时候使用了EPOLLET标志,那么在第5步调用epoll_wait(2)之后将有可能会挂起,因为剩余的数据还存在于文件的输入缓冲区内,而且数据发出端还在等待一个针对已经发出数据的反馈信息。只有在监视的文件句柄上发生了某个事件的时候 ET 工作模式才会汇报事件。因此在第5步的时候,调用者可能会放弃等待仍在存在于文件输入缓冲区内的剩余数据。

当使用epoll的ET模型来工作时,当产生了一个EPOLLIN事件后,
读数据的时候需要考虑的是当recv()返回的大小如果等于请求的大小,那么很有可能是缓冲区还有数据未读完,也意味着该次事件还没有处理完,所以还需要再次读取:

while(rs){
  buflen = recv(activeevents[i].data.fd, buf, sizeof(buf), 0);
  if(buflen < 0){
    // 由于是非阻塞的模式,所以当errno为EAGAIN时,表示当前缓冲区已无数据可读
    // 在这里就当作是该次事件已处理处.
    if(errno == EAGAIN){
        break;
    }
    else{
        return;
    }
  }
  else if(buflen == 0){
     // 这里表示对端的socket已正常关闭.
  }

 if(buflen == sizeof(buf){
      rs = 1;   // 需要再次读取
 }
 else{
      rs = 0;
 }
}

Linux中的EAGAIN含义

Linux环境下开发经常会碰到很多错误(设置errno),其中EAGAIN是其中比较常见的一个错误(比如用在非阻塞操作中)。
从字面上来看,是提示再试一次。这个错误经常出现在当应用程序进行一些非阻塞(non-blocking)操作(对文件或socket)的时候。

例如,以 O_NONBLOCK的标志打开文件/socket/FIFO,如果你连续做read操作而没有数据可读。此时程序不会阻塞起来等待数据准备就绪返回,read函数会返回一个错误EAGAIN,提示你的应用程序现在没有数据可读请稍后再试。
又例如,当一个系统调用(比如fork)因为没有足够的资源(比如虚拟内存)而执行失败,返回EAGAIN提示其再调用一次(也许下次就能成功)。

三 代码演示

下面是一段不完整的代码且格式不对,意在表述上面的过程,去掉了一些模板代码。

#define IPADDRESS   "127.0.0.1"
#define PORT        8787
#define MAXSIZE     1024
#define LISTENQ     5
#define FDSIZE      1000
#define EPOLLEVENTS 100

listenfd = socket_bind(IPADDRESS,PORT);

struct epoll_event events[EPOLLEVENTS];

//创建一个描述符
epollfd = epoll_create(FDSIZE);

//添加监听描述符事件
add_event(epollfd,listenfd,EPOLLIN);

//循环等待
for ( ; ; ){
    //该函数返回已经准备好的描述符事件数目
    ret = epoll_wait(epollfd,events,EPOLLEVENTS,-1);
    //处理接收到的连接
    handle_events(epollfd,events,ret,listenfd,buf);
}

//事件处理函数
static void handle_events(int epollfd,struct epoll_event *events,int num,int listenfd,char *buf)
{
     int i;
     int fd;
     //进行遍历;这里只要遍历已经准备好的io事件。num并不是当初epoll_create时的FDSIZE。
     for (i = 0;i < num;i++)
     {
         fd = events[i].data.fd;
        //根据描述符的类型和事件类型进行处理
         if ((fd == listenfd) &&(events[i].events & EPOLLIN))
            handle_accpet(epollfd,listenfd);
         else if (events[i].events & EPOLLIN)
            do_read(epollfd,fd,buf);
         else if (events[i].events & EPOLLOUT)
            do_write(epollfd,fd,buf);
     }
}

//添加事件
static void add_event(int epollfd,int fd,int state){
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_ADD,fd,&ev);
}

//处理接收到的连接
static void handle_accpet(int epollfd,int listenfd){
     int clifd;     
     struct sockaddr_in cliaddr;     
     socklen_t  cliaddrlen;     
     clifd = accept(listenfd,(struct sockaddr*)&cliaddr,&cliaddrlen);     
     if (clifd == -1)         
     perror("accpet error:");     
     else {         
         printf("accept a new client: %s:%d\n",inet_ntoa(cliaddr.sin_addr),cliaddr.sin_port);                       //添加一个客户描述符和事件         
         add_event(epollfd,clifd,EPOLLIN);     
     } 
}

//读处理
static void do_read(int epollfd,int fd,char *buf){
    int nread;
    nread = read(fd,buf,MAXSIZE);
    if (nread == -1)     {         
        perror("read error:");         
        close(fd); //记住close fd        
        delete_event(epollfd,fd,EPOLLIN); //删除监听 
    }
    else if (nread == 0)     {         
        fprintf(stderr,"client close.\n");
        close(fd); //记住close fd       
        delete_event(epollfd,fd,EPOLLIN); //删除监听 
    }     
    else {         
        printf("read message is : %s",buf);        
        //修改描述符对应的事件,由读改为写         
        modify_event(epollfd,fd,EPOLLOUT);     
    } 
}

//写处理
static void do_write(int epollfd,int fd,char *buf) {     
    int nwrite;     
    nwrite = write(fd,buf,strlen(buf));     
    if (nwrite == -1){         
        perror("write error:");        
        close(fd);   //记住close fd       
        delete_event(epollfd,fd,EPOLLOUT);  //删除监听    
    }else{
        modify_event(epollfd,fd,EPOLLIN); 
    }    
    memset(buf,0,MAXSIZE); 
}

//删除事件
static void delete_event(int epollfd,int fd,int state) {
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_DEL,fd,&ev);
}

//修改事件
static void modify_event(int epollfd,int fd,int state){     
    struct epoll_event ev;
    ev.events = state;
    ev.data.fd = fd;
    epoll_ctl(epollfd,EPOLL_CTL_MOD,fd,&ev);
}

//注:另外一端我就省了

四 epoll总结

在 select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一 个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait() 时便得到通知。(此处去掉了遍历文件描述符,而是通过监听回调的的机制。这正是epoll的魅力所在。)

epoll的优点主要是一下几个方面:
1. 监视的描述符数量不受限制,它所支持的FD上限是最大可以打开文件的数目,这个数字一般远大于2048,举个例子,在1GB内存的机器上大约是10万左 右,具体数目可以cat /proc/sys/fs/file-max察看,一般来说这个数目和系统内存关系很大。select的最大缺点就是进程打开的fd是有数量限制的。这对 于连接数量比较大的服务器来说根本不能满足。虽然也可以选择多进程的解决方案( Apache就是这样实现的),不过虽然linux上面创建进程的代价比较小,但仍旧是不可忽视的,加上进程间数据同步远比不上线程间同步的高效,所以也不是一种完美的方案。

  1. IO的效率不会随着监视fd的数量的增长而下降。epoll不同于select和poll轮询的方式,而是通过每个fd定义的回调函数来实现的。只有就绪的fd才会执行回调函数。

如果没有大量的idle -connection或者dead-connection,epoll的效率并不会比select/poll高很多,但是当遇到大量的idle- connection,就会发现epoll的效率大大高于select/poll。

参考

用户空间与内核空间,进程上下文与中断上下文[总结]
进程切换
维基百科-文件描述符
Linux 中直接 I/O 机制的介绍
IO - 同步,异步,阻塞,非阻塞 (亡羊补牢篇)
Linux中select poll和epoll的区别
IO多路复用之select总结
IO多路复用之poll总结
IO多路复用之epoll总结

查看原文

赞 558 收藏 1028 评论 63

大愚Talk 分享了头条 · 8月4日

走进Golang是一个系列,从使用角度出发,深入源码!非常赞

赞 2 收藏 0 评论 0

大愚Talk 发布了文章 · 6月24日

Golang技巧之默认值设置的高阶玩法

最近使用 GRPC 发现一个设计特别好的地方,非常值得借鉴。

我们在日常写方法的时候,希望给某个字段设置一个默认值,不需要定制化的场景就不传这个参数,但是 Golang 却没有提供像 PHPPython 这种动态语言设置方法参数默认值的能力。

低阶玩家应对默认值问题

以一个购物车举例。比如我有下面这样一个购物车的结构体,其中 CartExts 是扩展属性,它有自己的默认值,使用者希望如果不改变默认值时就不传该参数。但是由于 Golang 无法在参数中设置默认值,只有以下几个选择:

  1. 提供一个初始化函数,所有的 ext 字段都做为参数,如果不需要的时候传该类型的零值,这把复杂度暴露给调用者;
  2. ext 这个结构体做为一个参数在初始化函数中,与 1 一样,复杂度在于调用者;
  3. 提供多个初始化函数,针对每个场景都进行内部默认值设置。

下面看下代码具体会怎么做

const (
    CommonCart = "common"
    BuyNowCart = "buyNow"
)

type CartExts struct {
    CartType string
    TTL      time.Duration
}

type DemoCart struct {
    UserID string
    ItemID string
    Sku    int64
    Ext    CartExts
}

var DefaultExt = CartExts{
    CartType: CommonCart,       // 默认是普通购物车类型
    TTL:      time.Minute * 60, // 默认 60min 过期
}

// 方式一:每个扩展数据都做为参数
func NewCart(userID string, Sku int64, TTL time.Duration, cartType string) *DemoCart {
    ext := DefaultExt
    if TTL > 0 {
        ext.TTL = TTL
    }
    if cartType == BuyNowCart {
        ext.CartType = cartType
    }

    return &DemoCart{
        UserID: userID,
        Sku:    Sku,
        Ext:    ext,
    }
}

// 方式二:多个场景的独立初始化函数;方式二会依赖一个基础的函数
func NewCartScenes01(userID string, Sku int64, cartType string) *DemoCart {
    return NewCart(userID, Sku, time.Minute*60, cartType)
}

func NewCartScenes02(userID string, Sku int64, TTL time.Duration) *DemoCart {
    return NewCart(userID, Sku, TTL, "")
}

上面的代码看起来没什么问题,但是我们设计代码最重要的考虑就是稳定与变化,我们需要做到 对扩展开放,对修改关闭 以及代码的 高内聚。那么如果是上面的代码,你在 CartExts 增加了一个字段或者减少了一个字段。是不是每个地方都需要进行修改呢?又或者 CartExts 如果有非常多的字段,这个不同场景的构造函数是不是得写非常多个?所以简要概述一下上面的办法存在的问题。

  1. 不方便对 CartExts 字段进行扩展;
  2. 如果 CartExts 字段非常多,构造函数参数很长,难看、难维护;
  3. 所有的字段构造逻辑冗余在 NewCart 中,面条代码不优雅;
  4. 如果采用 CartExts 做为参数的方式,那么就将过多的细节暴露给了调用者。

接下来我们来看看 GRPC 是怎么做的,学习优秀的范例,提升自我的代码能力。

从这你也可以体会到代码功底牛逼的人,代码就是写的美!

GRPC 之高阶玩家设置默认值

源码来自:grpc@v1.28.1 版本。为了突出主要目标,对代码进行了必要的删减。

// dialOptions 详细定义在 google.golang.org/grpc/dialoptions.go
type dialOptions struct {
    // ... ...
    insecure    bool
    timeout     time.Duration
    // ... ...
}

// ClientConn 详细定义在 google.golang.org/grpc/clientconn.go
type ClientConn struct {
    // ... ...
    authority    string
    dopts        dialOptions // 这是我们关注的重点,所有可选项字段都在这里
    csMgr        *connectivityStateManager
    
    // ... ...
}

// 创建一个 grpc 链接
func DialContext(ctx context.Context, target string, opts ...DialOption) (conn *ClientConn, err error) {
    cc := &ClientConn{
        target:            target,
        csMgr:             &connectivityStateManager{},
        conns:             make(map[*addrConn]struct{}),
        dopts:             defaultDialOptions(), // 默认值选项
        blockingpicker:    newPickerWrapper(),
        czData:            new(channelzData),
        firstResolveEvent: grpcsync.NewEvent(),
    }
    // ... ...

    // 修改改选为用户的默认值
    for _, opt := range opts {
        opt.apply(&cc.dopts)
    }
    // ... ...
}

上面代码的含义非常明确,可以认为 DialContext 函数是一个 grpc 链接的创建函数,它内部主要是构建 ClientConn 这个结构体,并做为返回值。defaultDialOptions 函数返回的是系统提供给 dopts 字段的默认值,如果用户想要自定义可选属性,可以通过可变参数 opts 来控制。

经过上面的改进,我们惊奇的发现,这个构造函数非常的优美,无论 dopts 字段如何增减,构造函数不需要改动;defaultDialOptions 也可以从一个公有字段变为一个私有字段,更加对内聚,对调用者友好。

那么这一切是怎么实现的?下面我们一起学习这个实现思路。

DialOption 的封装

首先,这里的第一个技术点是,DialOption 这个参数类型。我们通过可选参数方式优化了可选项字段修改时就要增加构造函数参数的尴尬,但是要做到这一点就需要确保可选字段的类型一致,实际工作中这是不可能的。所以又使出了程序界最高手段,一层实现不了,就加一层。

通过这个接口类型,实现了对各个不同字段类型的统一,让构造函数入参简化。来看一下这个接口。

type DialOption interface {
    apply(*dialOptions)
}

这个接口有一个方法,其参数是 *dialOptions 类型,我们通过上面 for 循环处的代码也可以看到,传入的是 &cc.dopts。简单说就是把要修改的对象传入进来。apply 方法内部实现了具体的修改逻辑。

那么,这既然是一个接口,必然有具体的实现。来看一下实现。

// 空实现,什么也不做
type EmptyDialOption struct{}

func (EmptyDialOption) apply(*dialOptions) {}

// 用到最多的地方,重点讲
type funcDialOption struct {
    f func(*dialOptions)
}

func (fdo *funcDialOption) apply(do *dialOptions) {
    fdo.f(do)
}

func newFuncDialOption(f func(*dialOptions)) *funcDialOption {
    return &funcDialOption{
        f: f,
    }
}

我们重点说 funcDialOption 这个实现。这算是一个高级用法,体现了在 Golang 里边函数是 一等公民。它有一个构造函数,以及实现了 DialOption 接口。

newFuncDialOption 构造函数接收一个函数做为唯一参数,然后把传入的函数保存到 funcDialOption 的字段 f 上。再来看看这个参数函数的参数类型是 *dialOptions ,与 apply 方法的参数是一致的,这是设计的第二个重点。

现在该看 apply 方法的实现了。它非常简单,其实就是调用构造 funcDialOption 时传入的方法。可以理解为相当于做了一个代理。把 apply 要修改的对象丢到 f 这个方法中。所以重要的逻辑都是我们传入到 newFuncDialOption 这个函数的参数方法实现的。

现在来看看 grpc 内部有哪些地方调用了 newFuncDialOption 这个构造方法。

newFuncDialOption 的调用

由于 newFuncDialOption 返回的 *funcDialOption 实现了 DialOption 接口,因此关注哪些地方调用了它,就可以顺藤摸瓜的找到我们最初 grpc.DialContext 构造函数 opts 可以传入的参数。

调用了该方法的地方非常多,我们只关注文章中列出的两个字段对应的方法:insecuretimeout

// 以下方法详细定义在 google.golang.org/grpc/dialoptions.go
// 开启不安全传输
func WithInsecure() DialOption {
    return newFuncDialOption(func(o *dialOptions) {
        o.insecure = true
    })
}

// 设置 timeout
func WithTimeout(d time.Duration) DialOption {
    return newFuncDialOption(func(o *dialOptions) {
        o.timeout = d
    })
}

来体验一下这里的精妙设计:

  1. 首先对于每一个字段,提供一个方法来设置其对应的值。由于每个方法返回的类型都是 DialOption ,从而确保了 grpc.DialContext 方法可用可选参数,因为类型都是一致的;
  2. 返回的真实类型是 *funcDialOption ,但是它实现了接口 DialOption,这增加了扩展性。

grpc.DialContext 的调用

完成了上面的程序构建,现在我们来站在使用的角度,感受一下这无限的风情。


opts := []grpc.DialOption{
    grpc.WithTimeout(1000),
    grpc.WithInsecure(),
}

conn, err := grpc.DialContext(context.Background(), target, opts...)
// ... ...

当然这里要介绍的重点就是 opts 这个 slice ,它的元素就是实现了 DialOption 接口的对象。而上面的两个方法经过包装后都是 *funcDialOption 对象,它实现了 DialOption 接口,因此这些函数调用后的返回值就是这个 slice 的元素。

现在我们可以进入到 grpc.DialContext 这个方法内部,看到它内部是如何调用的。遍历 opts,然后依次调用 apply 方法完成设置。

// 修改改选为用户的默认值
for _, opt := range opts {
    opt.apply(&cc.dopts)
}

经过这样一层层的包装,虽然增加了不少代码量,但是明显能够感受到整个代码的美感、可扩展性都得到了改善。接下来看一下,我们自己的 demo 要如何来改善呢?

改善 DEMO 代码

首先我们需要对结构体进行改造,将 CartExts 变成 cartExts, 并且需要设计一个封装类型来包裹所有的扩展字段,并将这个封装类型做为构造函数的可选参数。


const (
    CommonCart = "common"
    BuyNowCart = "buyNow"
)

type cartExts struct {
    CartType string
    TTL      time.Duration
}

type CartExt interface {
    apply(*cartExts)
}

// 这里新增了类型,标记这个函数。相关技巧后面介绍
type tempFunc func(*cartExts)

// 实现 CartExt 接口
type funcCartExt struct {
    f tempFunc
}

// 实现的接口
func (fdo *funcCartExt) apply(e *cartExts) {
    fdo.f(e)
}

func newFuncCartExt(f tempFunc) *funcCartExt {
    return &funcCartExt{f: f}
}

type DemoCart struct {
    UserID string
    ItemID string
    Sku    int64
    Ext    cartExts
}

var DefaultExt = cartExts{
    CartType: CommonCart,       // 默认是普通购物车类型
    TTL:      time.Minute * 60, // 默认 60min 过期
}

func NewCart(userID string, Sku int64, exts ...CartExt) *DemoCart {
    c := &DemoCart{
        UserID: userID,
        Sku:    Sku,
        Ext:    DefaultExt, // 设置默认值
    }
    
    // 遍历进行设置
    for _, ext := range exts {
        ext.apply(&c.Ext)
    }

    return c
}

经过这一番折腾,我们的代码看起来是不是非常像 grpc 的代码了?还差最后一步,需要对 cartExts 的每个字段包装一个函数。


func WithCartType(cartType string) CartExt {
    return newFuncCartExt(func(exts *cartExts) {
        exts.CartType = cartType
    })
}

func WithTTL(d time.Duration) CartExt {
    return newFuncCartExt(func(exts *cartExts) {
        exts.TTL = d
    })
}

对于使用者来说,只需如下处理:

exts := []CartExt{
    WithCartType(CommonCart),
    WithTTL(1000),
}

NewCart("dayu", 888, exts...)

总结

是不是非常简单?我们再一起来总结一下这里代码的构建技巧:

  1. 把可选项收敛到一个统一的结构体中;并且将该字段私有化;
  2. 定义一个接口类型,这个接口提供一个方法,方法的参数应该是可选属性集合的结构体的指针类型,因为我们要修改其内部值,所以一定要指针类型;
  3. 定义一个函数类型,该函数应该跟接口类型中的方法保持一致的参数,都使用可选项收敛的这个结构体指针作为参数;(非常重要)
  4. 定义一个结构体,并实现 2 中的接口类型;(这一步并非必须,但这是一种良好的编程风格)
  5. 利用实现了接口的类型,封装可选字段对应的方法;命令建议用 With + 字段名 的方式。

按照上面的五步大法,你就能够实现设置默认值的高阶玩法。

如果你喜欢这个类型的文章,欢迎留言点赞!

查看原文

赞 11 收藏 5 评论 0

大愚Talk 分享了头条 · 6月24日

Grpc中的很多代码写的非常优雅,站在巨人的肩膀上不断前行!

赞 0 收藏 0 评论 0

大愚Talk 分享了头条 · 5月18日

Golang的channel是CSP的重要实现,该如何正确使用?本文给了有用的指导!

赞 1 收藏 1 评论 0

大愚Talk 分享了头条 · 4月6日

基于DDD的购物车实战,对购物车如何编码实现有深度剖析!

赞 0 收藏 19 评论 0

大愚Talk 发布了文章 · 4月6日

[Skr-Shop]购物车之架构设计

来还债了,希望大家在疫情中都是平安的,回来的时候公司也还在!


skr shop是一群底层码农,由于被工作中的项目折磨的精神失常,加之由于程序员的自傲:别人设计的系统都是一坨shit,我的设计才是宇宙最牛逼,于是乎决定要做一个只设计不编码的电商设计手册。

在上一篇文章 购物车设计之需求分析 描述了购物车的通用需求。本文重点则在如何实现上进行架构上的设计(业务+系统架构)。

说明

架构设计可以分为三个层面:

  • 业务架构
  • 系统架构
  • 技术架构

快速简单的说明下三个架构的意思;当我们拿到购物车需求时,我们说用Golang来实现,存储用Redis;这描述的是技术架构;我们对购物车代码项目进行代码分层,设计规范,以及依赖系统的规划这叫系统架构;

那业务架构是什么呢?业务架构本质上是对系统架构的文字语言描述;什么意思?我们拿到一个需求首先要跟需求方进行沟通,建立统一的认知。比如:规范名词(购物车中说的商品与商品系统中商品的含义是不同的);建立大家都能明白的模型,购物车、用户、商品、订单这些实体之间的互动,以及各自具备什么功能。

在业务架构分析上有很多方法论,比如:领域驱动设计,但是它并不是唯一的业务架构分析方法,也并不是说最好的。适合你的就是最好的。我们常用的实体关系图、UML图也属于业务架构领域;

这里需要强点一点的是,不管你用什么方式来建模设计,有设计总比没设计强,其次一定要将建模的内容体现到你的代码中去。

本文在业务架构上的分析借助了 DDD (领域驱动设计)思想;还是那句话适合的就是最好的

业务架构

通过前面的需求分析,我们已经明确我们的购物车要干什么了。先来看一下一个典型的用户操作购物车过程。

用户旅程

在这个过程中,用户使用购物车这个载体完成了商品的购买流程;不断流动的数据是商品,购物车这个载体是稳定的。这是我们系统中的稳定点与变化点。

商品的流动方式可能多种多样,比如从不同地方加入购物车,不同方式加入购物车,生命周期在购物车中也不一样;但是这个流程是稳定的,一定是先让购物车中存在商品,然后才能去结算产生订单。

商品在购物车中的生命周期如下:

过程

按照这个过程,我们来看一下每个阶段对应的操作。

过程对应的操作

这里注意一点,加车前这个操作其实我们可以放到购物车的添加操作中,但是由于这部分是非常不稳定且多变的。我们将其独立出来,方便后续进行扩展而不影响相对比较稳定的购物车阶段。

上面这三个阶段,按照DDD中的概念,应该叫做实体,他们整体构成了购物车这个域;今天我们先不讲这些概念,就先略过,后面有机会单独发文讲解。

加车前

通过流程分析,我们总结出了系统需要具备的操作接口,以及这些接口对应的实体,现在我们先来看加车前主要要做些什么;

加车前其实主要就是对准备加入的购物车商品进行各个纬度的校验,检查是否满足要求。

在让用户加车前,我们首先解决的是用户从哪里卖,然后进行验证?因为同一个商品从不同渠道购买是存在不同情况的,比如:小米手机,我们是通过秒杀买,还是通过好友众筹买,或者商城直接购买,价格存在差异,但是实际上他是同一个商品;

第二个问题是是否具备购买资格,还是上面说的,秒杀、众筹这个加车操作,不是谁都可以添加的,得现有资格。那么资格的检查也是放到这里;

第三个问题是对这个购买的商品进行商品属性上的验证,如是否上下架,有库存,限购数量等等。

而且大家会发现,这里的验证条件可能是非常多变的。如何构建一个方便扩展的代码呢?

加车的验证

整个加车过程,重要的就是根据来源来区分不同的验证。我们有两种选择方式。

方式一:通过策略模式+门面模式的方式来搞定。策略就是根据不同的加车来源进行不同的验证,门面就是根据不同的来源封装一个个策略;

方式二:通过责任链模式,但是这里需要有一个变化,这个链在执行过程中,可以选择跳过某些节点,比如:秒杀不需要库存、也不需要众筹的验证;

通过综合的分析我选择了责任链的模式。贴一下核心代码

// 每个验证逻辑要实现的接口
type Handler interface {
    Skipped(in interface{}) bool // 这里判断是否跳过
    HandleRequest(in interface{}) error // 这里进行各种验证
}

// 责任链的节点
type RequestChain struct {
    Handler
    Next *RequestChain
}

// 设置handler
func (h *RequestChain) SetNextHandler(in *RequestChain) *RequestChain {
    h.Next = in
    return in
}

关于设计模式,大家可以看我小伙伴的github:https://github.com/TIGERB/eas...

购物车

说完了加车前,现在来看购物车这一部分。我们在之前曾讨论过,购物车可能会有多种形态的,比如:存储多个商品一起结算,某个商品立即结算等。因此购物车一定会根据渠道来进行购物车类型的选择。

这部分的操作相对是比较稳定的。我们挑几个比较重要的操作来讲一下思路即可。

加入购物车

通过把条件验证的前置,会发现在进行加车操作时,这部分逻辑已经变得非常的轻量了。要做的主要是下面几个部分的逻辑。

加入购物车

这里有几个取巧的地方,首先是获取商品的逻辑,由于在前面验证的时候也会用到,因此这里前面获取后会通过参数的方式继续往后传递,因此这里不需要在读库或者调用服务来获取;

其次这里需要把当前用户现有购物车数据获取到,然后将添加的这个商品添加进来。这是一个类似合并操作,原来这个商品是存在,相当于数量加一;需要注意这个商品跟现存的商品有没有父子关系,有没有可能加入后改变了某个活动规则,比如:原来买了2个送1个赠品,现在再添加了一个变成3个,送2个赠品;

注意:这里的添加并不是在购物车直接改数量,可能就是在列表、详情页直接添加添加。

通过将合并后的购物车数据,通过营销活动检查确认ok后,直接回写到存储中。

合并购物车

为什么会有合并购物车这个操作?因为一般电商都是准许游客身份进行操作的,因此当用户登录后需要将二者进行合并。

这里的合并很多部分的逻辑是可以与加入购物车复用的逻辑。比如:合并后的数据都需要检查是否合法,然后覆写回存储中。因此大家可以看到这里的关联性。设计的方法在某种程度上要通用。

购物车列表

购物车列表这是一个非常重要的接口,原则上购物车接口会提供两种类型,一种简版,一种完全版本;

简版的列表接口主要是用在类似PC首页右上角之类获取简单信息;完全版本就是在购物车列表中会用到。

在实际实现中,购物车绝不仅仅是一个读取接口那么简单。因为我们都知道不管是商品信息、活动信息都是在不断的发生变化。因此每次的读取接口必然需要检查当前购物车中数据的合法性,然后发现不一致后需要覆写原存储的数据。

购物车列表

也有一些做法会在每个接口都去检查数据的合法性,我建议为了性能考虑,部分接口可以适当放宽检查,在获取列表时再进行完整的检查。比如添加接口,我只会检测我添加的商品的合法性,绝不会对整个购物车进行检查。因为该操作之后一般都会调用列表操作,那么此时还会进行校验,二者重复操作,因此只取后者。

结算

结算包括两部分,结算页的详情信息与提交订单。结算页可以说是在购物车列表上的一个包装,因为结算页与列表页最大的不同是需要用户选择配送地址(虚拟商品另说),此时会产生更明确的价格信息,其他基本一致。因此在设计购物车列表接口的时候,一定要考虑充分的通用性。

这里另外一个需要注意的是:立即购买,我们也会通过结算页接口来实现,但是内部其实还是会调用添加接口,将商品添加到购物车中;有三个需要注意的地方,首先是这个添加操作是服务内部完成的,对于服务调用方是不需要感知这个加入操作的存在;其次是这个购物车在Redis中的Key是独立于普通购物车的,否则二者的商品耦合在一起非常难于操作处理;最后立即购买的购物车要考虑账号多终端登录的时候,彼此数据不能互相影响,这里可以用每个端的uuid来作为购物车的标记避免这种情况。

购物车的最后一步是生成订单,这一步最要紧的是需要给购物车加锁,避免提交过程中数据被篡改,多说一句,很多人写的Redis分布式锁代码都存在缺陷,大家一定要注意原子性的问题,这类文章网络上很多不再赘述。

加锁成功之后,我们这里有多种做法,一种是按照DB涉及组织数据开始写表,这适用于业务量要求不大,比如订单每秒下单量不超过2000K的;那如果你的系统并发要求非常高怎么办?

其实也很简单,高性能的三大法宝之一:异步;我们提交的时候直接将数据快照写入MQ中,然后通过异步的方式进行消费处理,可以通过通过控制消费者的数量来提升处理能力。这种方法虽然性能提升,但是复杂度也会上升,大家需要根据自己的实际情况来选择。

关于业务架构的设计,到此告一段落,接下来我们来看系统架构。

系统架构

系统结构主要包含,如何将业务架构映射过来,以及输出对应输入参数、输出参数的说明。由于输入、输出针对各自业务来确定的,而且没有什么难度,我们这里就只说如何将业务架构映射到系统架构,以及系统架构中最核心的Redis数据结构选择以及存储的数据结构设计。

代码结构

下面的代码目录是按照 Golang 来进行设计的。我们来看看如何将上面的业务架构映射到代码层面来。

├── addproducts.go
├── cartlist.go
├── mergecart.go
├── entity
│   ├── cart
│   │   ├── add.go
│   │   ├── cart.go
│   │   └── list.go
│   ├── order
│   │   ├── checkout.go
│   │   ├── order.go
│   │   └── submit.go
│   └── precart
├── event
│   └── sendorder.go
├── facade
│   ├── activity.go
│   └── product.go
└── repo

外层有 entityeventfacaderepo这四个目录,职责如下:

entity: 存放的是我们前面分析的购物领域的三个实体;所有主要的操作都在这三个实体上;

event: 这是用来处理产生的事件,比如刚刚说的如果我们提交订单采用异步的方式,那么该目录就该完成的是如何把数据发送到MQ中去;

facade: 这儿目录是干嘛的呢?这主要是因为我们的服务还需要依赖像商品、营销活动这些服务,那么我们不应该在实体中直接调用它,因为第三方可能存在变动,或者有增加、减少,我们在这里进行以下简单的封装(设计模式中的门面模式);

repo: 这个目录从某种程度上可以理解为 Model层,在整个领域服务中,如果与持久化打交道,都通过它来完成。

最后外层的几个文件,就是我们所提供的领域服务,供应用层来进行调用的。

为了保证内容的紧凑,我这里放弃了对整个微服务的目录介绍,只单独介绍了领域服务,后续会单独成文介绍下微服务的整个系统架构。

通过上面的划分,我们完成了两件事情:

  1. 业务架构分析的结构在系统代码中都有映射,他们彼此体现。这样最大的好处是,保证设计与代码的一致性,看了文档你就知道对应的代码在哪里;
  2. 每个目录各自的关注点都进行了分离,更内聚,更容易开发与维护。

Redis存储

现在来看,我们选择Redis作为购物商品数据的存储,我们要解决两个问题,一是我们需要存哪些数据?二是我们用什么结构来存?

网络上很多写购物车的都是只保存一个商品id,真实场景是很难满足需求的。你想想,一个商品id如何记住用户选择的赠品?用户上次选择的活动?以及购买的商品渠道?

综合比较通用的场景,我给出一个参考结构:

// 购物车数据
type ShoppingData struct {
    Item       []*Item `json:"item"`
    UpdateTime int64   `json:"update_time"`
    Version    int32   `json:"version"`
}

// 单个商品item元素
type Item struct {
    ItemId       string          `json:"item_id"`
    ParentItemId string          `json:"parent_item_id,omitempty"` // 绑定的父item id
    OrderId      string          `json:"order_id,omitempty"`       // 绑定的订单号
    Sku          int64           `json:"sku"`
    Spu          int64           `json:"spu"`
    Channel      string          `json:"channel"`
    Num          int32           `json:"num"`
    Status       int32           `json:"status"`
    TTL          int32           `json:"ttl"`                     // 有效时间
    SalePrice    float64         `json:"sale_price"`              // 记录加车时候的销售价格
    SpecialPrice float64         `json:"special_price,omitempty"` // 指定价格加购物车
    PostFree     bool            `json:"post_free,omitempty"`     // 是否免邮
    Activities   []*ItemActivity `json:"activities,omitempty"`    // 参加的活动记录
    AddTime      int64           `json:"add_time"`
    UpdateTime   int64           `json:"update_time"`
}

// 活动
type ItemActivity struct {
    ActID    string `json:"act_id"`
    ActType  string `json:"act_type"`
    ActTitle string `json:"act_title"`
}

重点说一下 Item 这个结构,item_id 这个字段是标记购物车中某个商品的唯一标记,因为我们之前说过,同一个sku由于渠道不同,那么在购物车中会是两个不同的item;接下来的 parent_item_id 字段是用来标记父子关系的,这里将可能存在的树结构转成了顺序结构,我们不管是父商品还是子商品,都采用顺序存储,然后通过这个字段来进行关联;有些同学可能会奇怪,为什么会存order id这个字段呢?大家关注下自己的日常业务,比如:再来一单、定金预售等,这种一定是与某个订单相关联的,不管是为了资格验证还是数据统计。剩下的字段都是一些非常常规的字段,就不在一一介绍了;

字段的类型,大家根据自己的需要进行修改。

接下来该说怎么选择Redis的存储结构了,Redis常用的 Hash Table、集合、有序集合、链表、字符串 五种,我们一个个来分析。

首先购车一定有一个key来标记这个购物车属于哪个用户的,为了简化,我们的key假设是:uid:cart_type

我们先来看如果用 Hash Table;我们添加时,需要用到如下命令:HSET uid:cart_type sku ShoppingData;看起来没问题,我们可以根据sku快速定位某个商品然后进行相关的修改等,但是注意,ShoppingData是一个json串,如果用户购物车中有非常多的商品,我们用 HGETALL uid:cart_type 获取到的时间复杂度是O(n),然后代码中还需要一一反序列化,又是O(n)的复杂度。

如果用集合,也会遇到类似的问题,每个购物车看做一个集合,集合中的每个元素是 ShoppingData ,取到代码中依然需要逐一反序列化(反序列化是成本),关于有序集合与链表就不在分析,大家可以按照上面的思路去尝试下问题所在。

看起来我们没得选,只有使用String,那我们来看一下String的契合度是什么样子。首先SET uid:cart_type ShoppingDataArr;我们把购物车所有的数据序列化成一个字符串存储,每次取出来的时间复杂度是O(1),序列化、反序列化都只需要一次。看来是非常不错的选择。但是在使用中大家还是有几点需要注意。

  1. 单个Value不能太大,要不然就会出现大key问题,所以一般购物车有上限限制,比如item不能超过多少个;
  2. 对redis的操作性能提升上来了,但是代码的就是修改单个item时的不便,必须每次读取全部然后找到对应的item进行修改;这里我们可以把从redis中的数据读取出来后,在内存中构建一个HashTable,来减少每次遍历的复杂度;

网上也看到很多Redis数据结构组合使用来保存购物车数据的,但是无疑增加了网络开销,相比起来还是String最经济划算。

总结

至此对于购物车的实现设计算是完结了,其中关于订单表的设计会单独放到订单模块去讲。

对于整个购物车服务,虽然没有写的详细到某个具体的接口,但是分析到这一步,我相信大家心中都是有沟壑的,能够结合自己的业务去实现它。

文中有些很有意思的地方,建议大家动手去做做看,有任何问题,我们随时交流。

  • 改编版的责任链模式
  • Redis的分布式事务锁实现

接下来终于要到订单部分的设计了,希望大家继续关注我们。

项目地址:https://github.com/skr-shop/m...

查看原文

赞 31 收藏 19 评论 5

大愚Talk 发布了文章 · 2019-12-29

再见2019,不畏将来,不念过往

不乱于心,不困于情。 不畏将来,不念过往。 如此,安好!

上面这句话出自 丰子恺的《不宠无惊过一生》 ,这这句是一种生活的境界,如若真能做到,算是活到了人生至高境界!

回顾2019年,犹如东逝之水,奔流而不复回;用此为文总结一下点点滴滴,以此来为2020查漏补缺。

关于公众号

今年总共写了 10篇 文章,数量上远远少于去年立的flag;不过今年文章的整体质量上是有所提升的。绝大部分都是花费了至少2周的时间去总结、学习然后才成文,每篇的写字时间也是至少5个小时才完成。今年也尝试写了一些技术之外的东西,虽说这样显得公众号可能有些不伦不类,不过我写这些文字的初衷更多是为自己留下一些记录。

下面来总结下今年的文章。首先是 Skr Shop 系列到目前止于小伙伴们共完成了 8篇,总计 842个star;距离我们年初定的 800star 算是完成KPI。

[Skr-Shop]通用抽奖工具之需求分析

[Skr-Shop]营销体系开篇

[Skr-Shop]购物车设计之需求分析

[Skr-Shop]coder,你会设计交易系统吗?(实干篇)

[Skr-Shop]coder,你会设计交易系统吗?(概念篇)

[Skr-Shop]电商设计手册之基础商品信息

[Skr-Shop]支付开发,不得不知的国内、国际第三方支付流程

[Skr-Shop]电商设计手册之用户体系

很多人觉得业务没有意思,但互联网的基础就是业务从线下到线上的转移,如何带给用户体验更好、更稳定的服务是最重要的事情。2020年还会继续努力完善这个系列,也真的希望这个系列针对能够对做电商、系统设计的人有帮助。


在技术方面写了 3篇 协议相关的文章;有 2篇 Golang相关的。这算是对工作技能的一些总结。

走进Golang之运行与Plan9汇编

走进Golang之编译器原理

高并发架构的HTTP知识介绍

高并发架构的TCP知识的介绍

高并发架构的CDN知识介绍

3篇协议相关的文章内容是相当干,面试、实际工作都很有用,不过标题当时定的有些招摇过市,不过当时是想的写一些列相关高并发相关的文章,结果写了3篇就半途而废,导致无法自圆其说,有人说我标题党,我也认。后面2篇是因为自己接触Golang有将近两三年的时间,希望通过走进Golang这一个系列来完成知识的回顾与深入。作为一个程序员最重要的就是语言、数据结构、算法,所以这个系列会坚持写下去。这两篇文章也在 Go中国Go语言中文网获得转发,也算是对自己学习的一种认可。

还有一篇是

今天,应该为了明天而活

这篇文章算是一篇读后感《财富自由之路》;最近这几年读了很多关于投资理财的书籍,自己感觉也慢慢树立了自己的投资理念。从一开始随机买,随机卖;到现在有组合的概念。这其中有几本书起到至关重要的作用。

《财务自由之路》,《钱:7步创造终身收入》,《哈利.布朗的永久投资组合》

关于理财

上面说了最近几年看了很多理财的东西,我想关于这部分多说一点。首先是为什么要理财?不是为了暴富,是为了将来;因为你一旦开始理财就会养成储蓄的习惯,而为了让储蓄的钱不断产生价值,直到某个临界点实现理财收入全覆盖你的日常开支(这是我的目标)。为了让储蓄的钱不断产生收益,我们需要学习正确的方法来操作。要不然放进去的时候是1w,取出来变1k。

关于学习理财,强烈推荐上面的三本书,微信读书里边就有。当然你也可以通过下面的链接购买纸质版本。下面给大家看一下我自己搭建的两个投资组合的收益情况。

先来看看我自己设立的财务安全的组合(这个组合的意思是:未来收益可以覆盖我的生活支出,换句话说只要我的生活成本不变,这个组合可以让我不用工作)。

财务安全

这里边的组成主要是在且慢跟投的长赢计划,自己购买的几个指数基金组合,还有部分A股股票(这部分后面会用其它资产替换掉),以及部分港股打新收益。这个组合今年的最大回撤是 3.87%

宝宝小金库

这一个组合是我将宝宝的所有零花钱给他存起来。宝宝是今年九月份出生的,所以目前这个组合实际情况还有待检验。这是完全按照《哈利.布朗的永久投资组合》提供的组合来创建的,股票、黄金、债券、现金各占25%。目前为止的最大回撤是 0.76%,当然这个数据由于时间太短没什么参考价值,明年再看。

对于每一个人,我觉得理财首先是必须要去做的,而且越早越好,并且理财中的学习过程非常有趣。这里推荐给大家一个桥水基金老大 Ray Dalio 的一个关于经济机器是怎样运行的视频,大家感受一下。

<iframe data-original="//player.bilibili.com/player.html?aid=52328643&cid=91586931&page=1" scrolling="no" border="0" frameborder="no" framespacing="0" allowfullscreen="true"> </iframe>

关于工作

说完了公众号、理财,最后谈一谈工作。

今年在公司的每一件任务基本上都完成了,距离2019年初定的OKR也基本达成;但是反思一下工作中其实有很多做得不够好,或者说自己感觉到乏力;因为涉及的人越来越多,想把每一件事情做到精致该如去保证?自己也在不断探索如何去提高自己。现在真的是有点明白,为什么大公司喜欢收购。

总的来说2019年的工作都比较顺利,完成了一次晋升、一次涨薪,但是做的事情基本上都还在我的舒适区。

工作是大多数人创造美好生活的唯一途径,也是证明自己价值的一个最佳平台;该如何发挥自己的价值?公司不会适应你,只能自己去适应公司(或者找到与自己个性匹配的公司,不过可能性为0)。如何在规则既定的前提下最大化公司与个人价值,我想我得在2020年继续去探索。

2020,你好

2019最幸福的事情不过于宝宝的诞生,宝宝的诞生让每一件事情都有了不一样的意义;2019最多的纠结是公司要求的能力与自己想要提高的能力发生冲突;2019最大的收获是对于学习方法有了新的认知,也在不断受益于此。

2020年工作上希望自己有机会有能力去挑战一些舒适区之外的事情,总要做一些一眼看不到结果、未知不可预测的事情才会更有意思。

理财上继续学习、按照现有的策略不断执行检查、调整更适合自己的方法。

技术上2020年最重要的就是把走进Golang这个技术系列写完,然后Skr Shop这个业务系列不断更新,简单定个计划的话每个月两种类型各1篇,一年算下来也有24篇文章(不知道明年底会不会打脸😂)。

最后想说读书真的很重要,因为只有读书才能打开视野,看到不一样的东西。当然如果你有机会实战那就更棒了!2020相信我们都会更棒。

查看原文

赞 1 收藏 1 评论 1

大愚Talk 发布了文章 · 2019-12-10

[skr shop]购物车设计之需求分析

skr shop是一群底层码农,由于被工作中的项目折磨的精神失常,加之由于程序员的自傲:别人设计的系统都是一坨shit,我的设计才是宇宙最牛逼,于是乎决定要做一个只设计不编码的电商设计手册。

项目地址:https://github.com/skr-shop/m...

对于一个电商来讲,购物车是整个购买流程最重要的一步。因为电商发展到今天购物车不仅仅只是为了完成打包下单的功能;也是收藏、对比、促销提醒、相关推荐的重要展示窗口。如此多的能力我们该如何设计保证购物车的高性能、以及良好的扩展能力来满足未来的发展呢?

今天开始我们就以一个假定的场景来输出一个购物车设计:某某电商平台,是一个多租户模式(我们前面的诸多设计都是多租户模式),用户可以把商品加入到购物车,并切按照商户纬度来展示、排序。当然购物车也支持常规的各种操作:选择、删除、清空、商品失效等。并且有相关的促销能够提醒用户。同时为了监控、运营,要支撑购物车数据同步到监控、数仓等能力。

本文会从用户使用的角度以及服务端两个角度来讲解系统的能力。本篇我们的主要目的是说清楚购物车的能力以及一些逻辑。下一篇会进行购物车模型设计以及接口定义。

用户视角

我们先来定义一下在用户侧用户操作购物车的功能有哪些?

用户则需求

一个购物车基本的能力基本上都在上图中,下面我们一一来分解。

操作

我们从用户的角度来看,购物车对于用户来说可以添加商品到购物车(加购物车、立即购买都属于一种添加方式);加入进购物车后,不想要了可以删除该商品(删一个、删多个、清空);想多买可以修改购买数量,发现钱不够可以减少购买数量;或者发现红色的比白色更漂亮,可以在购物车方便的进行更换规格;对于一些价格很贵的商品,能够在购物车添加一些保障服务(其实是绑定的虚拟商品);在要去结算的时候,还会提供选择能力让用户决定哪些商品真的本次要购买。

通过上面的描述我们可以看到这个过程是有其内在联系的。这里说一下关于选中功能,业界有两种做法,各有优劣,我们来看一下。淘宝的产品选中状态是保存在客户端的,并且默认不选中,刷新、重新打开APP状态会消失;京东、苏宁这一类是保存在服务端,会记录用户选中状态。针对这两种情况各有优劣。

客户端:

  1. 性能,选中/不选中的逻辑直接放在本地做,减少网络请求
  2. 体验,多端不能同步,但是购物车相对来说更像是一个收藏夹,每次用户自己选择也无可厚非
  3. 计算,价格计算时需要上传本地选中商品(也可以本地计算)
  4. 实现,主要靠客户端实现,与服务端无关,研发解耦合

服务端:

  1. 性能,每次操作选中都需要调用服务端,而该操作可能很频繁,除了网络损耗,服务端也需要考虑该如何快速找到修改的商品
  2. 体验,多端同步状态,记录历史状态
  3. 计算,服务端可获取数据,请求时无须上传额外数据
  4. 实现,服务端与客户端需要商定如何交互,以及返回数据(每次选中会导致价格变化),耦合在一起

个人认为这两种方式并无谁具备明显优势,完全是一种基于业务模式以及团队情况来做选择。我们这里后续的设计会基于在服务端保存商品选中状态。

在整个操作逻辑中,有个两个比较重要的地方单独说明一下:购买方式与购物车内修改购买属性

购买方式

主要的购买方式有立即购买、加入购物车、拼团购三种方式。

首先普通的加入购物车没什么太多要说的。重点来看下立即购买与拼团。

立即购买在于操作上来说就是选择商品后直接到了订单确认页面,没有购物车中去结算这一步。但是它的实现却可以依赖购物车的逻辑来做,我们来看一下使用购物车与不使用购物车实现这个逻辑有什么差别?

如果使用购物车来实现,也就是用户点击立即购买时,商品本质上还是加入到购物车中,但这个购物车却与原型的购物车不同,因为该购物车只能加一个商品,并且每次操作都会被覆盖。在视角效果上也是直接从商品详情页面跳转到订单确认页面。来看看这种方式的好处

  1. 与购物车在订单确认、下单逻辑上一致,内部可以直接通过购物车获取数据
  2. 需要一个独立的专门用于一键购买的购物车来实现,内存有消耗

另外一种实现方式使用一个新的数据结构,因为一般来说一键购买更简单,它只需要商品信息、价格信息即可。每次交互均可以根据sku_id来获取。

  1. 订单确认、下单逻辑上需要进行改造,每次请求之间要传递约定参数
  2. 节省内存,上下交互通过sku_id来保证

我们会采用使用在服务端一键购买以独立的购物车形式来实现。购物车的数据模型一致,保证了后续处理流程上的一致。

对于拼团,他其实分为两部分,首先是开团这个动作,当团成立后。我们可以选择将成团的商品加入普通购物车,同时可以加购其它商品。也可以选择将成团商品加入一键购买的购物车,保证成团商品只能买一个。拼团模式更像是加入购物车的一个前置条件。本质上它对于购物车的设计没有影响。

购物车内修改购买属性

这里主要是指可以在购物车便捷的操作一些需要在spu纬度操作的事情,比如:变更规格(也就是更换sku),以及选择绑定到spu纬度的服务(保险、延保等)。

我们重点说一下选择绑定的服务。例如:我们买一个手机,厂家提供了延保、各种其它附加服务,一般情况这种服务都是虚拟商品。但是这有个特殊情况。这些保障服务首先不能单独购买,其次他是跟主商品的数量息息相关。比如买两个手机,如果选择了加购服务,那么这些服务的数量必须是2,这会是一个联动关系。

这些保障服务是不能进行单独购买的,它一定要跟特定的商品捆绑销售。

服务端在存储这部分数据时一定需要考虑如何保存这种层级关系,这部分我们后面模型设计的时候大家会看到。

绑定商品关系

提醒

促销提醒很简单,返回的购物车数据,每一个商品应该携带当前的促销信息。这部分重点在于怎么获取促销信息,会在服务端看到。

然后说下购物车数量的提醒,也就是显示当前购物车商品的数量。一般来说进入到APP就会调用一个接口,获取用户的未读消息数、购物车商品数等。这里是需要非常高的读取速度。那么这种需求该如何满足呢?

方案一: 我们可以设计一个结构保存了用户相关的这种提醒信息数量,每次直接读取这个数据即可。不需要去跟消息服务、购物车服务打交道拿这些数据。

方案二: 在消息、购物车的模型中均设计一个保存总数量的字段,在读取数据的接口中,通过并发的方式调用这些服务拿到数据后进行聚合,这样在速度上只取决于最慢的服务。

这里我们的设计会采用 方案二,因为这样在某种程度上效率可以得到保证,同时整个系统的结构数据的一致性更容易得到保障。当然这里有个细节一定要注意,并发读取一定要设计超时,不要因为某个服务读数问题而导致拖累整个接口的性能。

接下来再来看看促销,这部分除了提醒,还需要提供对应的入口,让用户完成促销的操作。比如说某个商品有券,那么可以直接提供入口去领取;可凑单,有入口进入凑单列表并选择商品等。这部分需要解决的问题是服务端该如何及时从商品纬度拿到这些促销活动。

从用户的视角看完了,我们再来站在研发的角度看看服务端有哪些事情要做

研发视角

还是先来看看需求的汇总图:

服务端则需求

存储

对于存储,首选肯定是内存存储,至于要不要落库,我觉得没有必要。说下我的理由:

  1. 购物车的数据相对变化非常频繁,落库成本比较高,如果异步方式落库,很难保障一致性
  2. 极端情况,cache奔溃了,仅仅需要用户重新加入购物车,并且我们可以通过cache的持久化机制来保证数据的恢复

所以对于购物车,我们只会把数据完全保存在内存中。

商品销售类型发生变化

现在我们来讨论 商品销售类型发生变化 这个问题。这是什么意思呢?大家想一下:比如我把A商品加入到购物车,但是一直没有结算。这时运营说针对A商品搞一个活动,拿出10个库存5折购。那么问题来了,对于之前购物车中就有该商品的用户该如何处理?这里解决的主要问题是:购物车有该商品的用户不能直接以5折买。几种方案,我们来看一下:

方案一: 促销配置后,所有购物车中有该商品的用户失效或删除,这个方案首先被pass,操作成本太高,并且用户体验差

方案二: 购物车中要区分同一个SKU,不同销售类型。也就是说在我们的购物车中不是按照SKU的纬度来加商品,而是通过 SKU+售卖类型 来生成一个唯一识别码。

可以看到 方案二 解决了同一个sku在购物车并存的问题,并且库存之前互相不影响。不过这里又有一个问题?商品的售卖类型(或者说这个标记),该怎么什么地方设置?好像商品系统可以设计、促销系统也可以设置。我们的逻辑中会在促销系统中进行配置。因为商品属于基础逻辑,如果一改就是全局库存受到影响。活动结束后很难做到自动正常售卖。因此这个标记应该落到活动中进行设置(活动设置时会通过促销系统获取该商品之前的活动是否互斥,以确保配置的活动不会互相矛盾)。

依赖系统

购物车系统依赖了非常多的其它系统。

  • 商品系统
  • 库存系统
  • 促销系统
  • 结算系统

这些依赖的系统,有的是为了传输数据,有的是为了获取数据。我们按照这两个纬度来看一下。

促销提醒与计算

服务端要解决的是促销的提醒与价格计算问题。

现来说计算,针对这部分最佳的方式是,调用结算中心的价格计算。我们来看一下购物车中的价格计算与订单结算时的价格计算的差异。

首先购物车中计算价格时不知道用户的地址,这会影响运费的计算;再是不知道用券的情况。那么其实如果解决了这两个问题,我们就可以让价格计算出自同一个逻辑,仅仅是部分入参不同罢了。因此我们这里计算时可以按照最高运费来计算,同时用券默认在购物车都不使用券。对于促销问题这里是可以通过促销系统确认选中的商品可以享受哪些价格的。因此促销的价格应该计算在内。

接下来在再来说说如何为用户高效的提供促销的信息。先从我们的配置视野出发。

我们在配置一个促销活动或者发一张券时,都是将多个商品归到一个促销活动或者券的下面。如果按照活动、券的纬度来获取商品效率相对比较高。

活动-商品

但是在购物车的场景中发生了一个变化。我们是需要从商品纬度获取到该商品的所有活动信息(全平台活动、店铺活动);
那么购物车中为了展示这些信息该怎么做?很常规的一个做法(也确实不少公司是这样):把所有活动信息取出来,遍历出所有跟该商品相关的信息。这种做法效率很低,并且无法满足大规模的应用场景,比如双十一期间。

因此这里为了满足该需求,促销系统需要提供一个能力按照商品获取对应促销(活动、券)。因此一般来讲促销系统配置的活动不能仅仅是按照活动纬度存储,同时还需要生成一份商品纬度的促销信息。

商品-活动

购物车数据分析

对于购物车数据来说,前端会通过埋点记录加入购物车数据的情况,但是前端埋点一般是记录触发了某个前端操作,但是并不知道该操作是否成功与否。以及无法及时了解当前整体购物车的数据情况。

为了让运营团队更完整的了解购物车当前情况,我们通过后端打本地日志,然后通过日志收集的方式将日志同步给数据、监控等服务。

失效与排序

还有两个小部分没有讲到,一是商品该如何失效,比如:库存没有了、下架了;二是购物车中的商品是多个店铺的,排序的策略是什么?

由于本文我们还只是讨论需求,不涉及具体的模型设计,因此只是介绍方案。首先是商品失效,这很像一个软删除操作,一旦设置,用户侧看到的商品将是无法进行结算的,只能进行删除操作。

对于排序我们会采用的设计是:根据某个店铺在购物车中最后发生操作的时间,最新的操作肯定在最上面。

结尾

通过上面我们基本上搞清楚了购物车设计中我们要做什么,依赖的系统要提供什么能力。下篇开始进入数据模型的设计、前后端接口设计。

如果你对购物车上面的需求还有哪些补充,欢迎留言。我们一起来完善。

项目地址:https://github.com/skr-shop/m...

查看原文

赞 35 收藏 27 评论 0

大愚Talk 分享了头条 · 2019-11-27

把底层了解讲的清清楚楚,特别是几幅图让人清晰易懂。

赞 0 收藏 17 评论 0

大愚Talk 发布了文章 · 2019-11-27

走进Golang之运行与Plan9汇编

本文目录速览:

通过上一篇走进Golang之汇编原理,我们知道了目标代码的生成经历了那些过程。今天我们一起来学习一下生成的目标代码如何在计算机上执行。以及通过查阅 Golang 的 Plan9 汇编来了解Golang的一些内部秘密。

Golang的运行环境

当我们把编译后的Go代码运行起来,它会以进程的方式出现在系统中。然后开始处理请求、数据,我们会看到这个进程占用了内存消耗、cpu占比等等信息。本文就是要来解释在程序的运行过程中,内存、CPU、操作系统(当然还有其它的硬件,文中关系不大,就不说了)是如何进行配合,完成了我们代码所指定的事情。

内存

首先,我们先来说说内存。先来看一个我们运行的go进程。

代码如下:

package main

import (
    "fmt"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/", sayHello)

    err := http.ListenAndServe(":9999", nil)
    if err != nil {
        log.Fatal("ListenAndServe: ", err)
    }
}

func sayHello(w http.ResponseWriter, r *http.Request) {
    fmt.Printf("fibonacci: %d\n", fibonacci(1000))

    _, _ = fmt.Fprint(w, "Hello World!")
}

func fibonacci(num int) int {
    if num < 2 {
        return 1
    }
    return fibonacci(num-1) + fibonacci(num-2)
}

来看一下执行情况

dayu.com >ps aux

USER               PID   %CPU  %MEM      VSZ     RSS    TT  STAT   STARTED    TIME     COMMAND
xxxxx              3584  99.2  0.1     4380456  4376   s003  R+    8:33下午   0:05.81  ./myhttp

这里我们先来不关注其它指标,先来看 VSZRSS

  • VSZ: 是指虚拟地址,他是程序实际操作的内存。包含了分配还没有使用的内存。
  • RSS: 是实际的物理内存,包含了栈内存与堆内存。

每一个进程都是运行在自己的内存沙盒里,程序被分配的地址都是 “虚拟内存”,物理内存对程序开发者来说实际是不可见的,而且虚拟地址比进程实际的物理地址要大的多。我们经常编程中取指针对应的地址实际就是虚拟地址。这里一定要注意区分虚拟内存与物理内存。来一张图感受一下。

虚拟内存与物理内存

这张图主要是为了说明两个问题:

  1. 程序使用的是虚拟内存,但是操作系统会把虚拟内存映射到物理内存;你会发现自己机器上所有进程的VSZ要大得多;
  2. 物理内存可以被多个进程共享,甚至一个进程内的不同地址可能映射的都是同一个物理内存地址。

上面搞明白了程序中的内存具体是指什么,接下来说明程序是如何使用内存的(虚拟内存),内存说白了就是比硬盘存取速度更快的一个硬件,为了方便内存的管理,操作系统把分配给进程的内存划分成了不同的功能块。像我们经常说的:代码区,静态数据区,堆区,栈区等。

这里借用一张网络上的图来看一下。

go语言内存的划分

这里就是我们程序(进程)在虚拟内存中的分布。

代码区:存放的就是我们编译后的机器码,一般来说这个区域只能是只读。

静态数据区:存放的是全局变量与常量。这些变量的地址编译的时候就确定了(这也是使用虚拟地址的好处,如果是物理地址,这些地址编译的时候是不可能确定的)。Data与BSS都属于这一部分。这部分只有程序中止(kill掉、crasg掉等)才会被销毁。

栈区:主要是 Golang 里边的函数、方法以及其本地变量存储的地方。这部分伴随函数、方法开始执行而分配,运行完后就被释放,特别注意这里的释放并不会清空内存。后面文章讲内存分配的时候再详细说;还有一个点需要记住栈一般是从高地址向低地址方向分配,换句话说:高地址属于栈低,低地址属于栈底,它分配方向与堆是相反的。

堆区:像 C/C++ 语言,堆完全是程序员自己控制的。但是 Golang 里边由于有GC机制,我们写代码的时候并不需要关心内存是在栈还是堆上分配。Golang 会自己判断如果变量的生命周期在函数退出后还不能销毁或者栈上资源不够分配等等情况,就会被放到堆上。堆的性能会比栈要差一些。原因也留到内存分配相关的文章再给大家介绍。

内存的结构搞明白了,我们的程序被加载到内存还需要操作系统来指挥才能正确运行。

补充一个比较重要的概念:

寻址空间:一般指的是CPU对于内存寻址的能力,通俗地说,就是能最多用到多少内存的一个问题。比如:32条地址线(32位机器),那么总的地址空间就有 2^32 个,如果是64位机器,就是 2^64 个寻址空间。可以使用 uname -a 来查看自己系统支持的位数字。

操作系统、CPU、内存互相配合

为了讲清楚程序运行与调用,我们得先理清楚操作系统、内存、CPU、寄存器这几者之间的关系。

  • CPU: 计算机的大脑,它才能理解并执行指令;
  • 寄存器:严格讲寄存器是CPU的组成部分,它主要负责CPU在计算时临时存储数据;当然CPU还有多级的高速缓存,与我们这里相关度不大,就略过,大家知道其目的是为了弥补内存与CPU速度的差距即可;
  • 内存:像上面内存被划分成不同区,每一部分存了不同的数据;当然这些区的划分、以及虚拟内存与物理内存的映射都是操作系统来做的;
  • 操作系统:控制各种硬件资源,为其它运行的程序提供操作接口(系统调用)及管理。

这里操作系统是一个软件,CPU、寄存器、内存(物理内存)都是实打实的硬件。操作系统虽然也是一堆代码写出来的。但是她是硬件对其它应用程序的接口。总的来讲操作系统通过系统调用控制所有的硬件资源,他把其它的程序调度到CPU上让其它程序执行,但是为了让每个程序都有机会使用CPU,CPU又通过时间中断把控制权交给操作系统。

让操作系统可以控制我们的程序,我们编写的程序需要遵循操作系统的规定。这样操作系统才能控制程序执行、切换进程等操作。

最后我们的代码被编译成机器码之后,本质就是一条条的指令。我们期望的就是CPU去执行完这些指令进而完成任务。而操作系统又能够帮助我们让CPU来执行代码以及提供所需资源的调用接口(系统调用)。是不是非常简单?

Go程序的调用规约

在上面我们知道整个虚拟内存被我们划分为:代码区、静态数据区、栈区、堆区。接下来要讲的Go程序的调用规约(其实就是函数、方法运行的规则),主要是涉及上面所说的栈部分(堆部分会在内存分配的文章里边去讲)。以及计算机软硬各个部分如何配合。接下来我们就来看一下程序的基本单位函数跟方法是怎么执行与相互调用的。

函数在栈上的分布

这一部分,我们先来了解一些理论,然后接着用一个实际的例子来分析一下。先通过一张图来看一下在 Golang 中函数是如何在栈上分布的。

几个涉及到的专业用语:

  • 栈:这里说的栈跟上面的解释含义一致。无论是进程、线程、goroutine都有自己的调用栈;
  • 栈帧:可以理解是函数调用时在栈上为函数所分配的区域;
  • 调用者:caller,比如:a函数调用了b函数,那么a就是调用者
  • 被调者:callee,还是上面的例子,b就是被调者

栈帧

这幅图所展示的就是一个 栈帧 的结构。也可以说栈桢是栈给一个函数分配的栈空间,它包括了函数调用者地址、本地变量、返回值地址、调用者参数等信息。

这里有几个注意点,图中的 BPSP都表示对应的寄存器。

  • BP:基址指针寄存器(extended base pointer),也叫帧指针,存放着一个指针,表示函数栈开始的地方。
  • SP:栈指针寄存器(extended stack pointer),存放着一个指针,存储的是函数栈空间的栈顶,也就是函数栈空间分配结束的地方,注意这里是硬件寄存器,不是Plan9中的伪寄存器。

BPSP 放在一起,一个表示开始(栈顶)、一个表示结束(栈低)。

有了上面的基础知识,接着下面用实际的例子来验证一下。

Go的调用实例

才开始,我们就从一个简单的函数开始来分析一下整个函数的调用过程(下面涉及到 Plan9 汇编,请别慌,大部分都能够看懂,并且我也会写注释)。

package main

func main() {
    a := 3
    b := 2
    returnTwo(a, b)
}

func returnTwo(a, b int) (c, d int) {
    tmp := 1 // 这一行的主要目的是保证栈桢不为0,方便分析
    c = a + b
    d = b - tmp
    return
}

上面有两个函数,main 定义了两个本地变量,然后调用 returnTwo 函数。returnTwo 函数有两个参数与两个返回值。设计两个返回值主要是一起来看一下 golang 的多返回值是如何实现的。接下来我们把上面的代码对应的汇编代码展示出来。

main函数

有几行代码需要特别解释下,

0x0000 00000 (test1.go:3)       TEXT    "".main(SB), ABIInternal, $56-0

这一行中的重点信息:$56-056 表示的该函数栈桢大小(两个本地变量,两个参数是int类型,两个返回值是int类型,1个保存base pointer,合计7 * 8 = 56);0表示 mian 函数的参数与返回值大小。待会可以在 returnTwo 中去看一下它的返回值又是多少。

接下来在看一下计算机是怎么在栈上分配大小的。

0x000f 00015 (test1.go:3)       SUBQ    $56, SP // 分配,56的大小在上面第一行定义了
... ...
0x004b 00075 (test1.go:7)       ADDQ    $56, SP // 释放掉,但是并未清空

这两行,一个是分配,一个是释放。为什么用了 SUBQ 指令就能进行分配呢?而 ADDQ 是释放?记得我们前面说过吗? SP 是一个指针寄存器,并且指向栈顶,栈又是从高地址向低地址分配。那么对它做一次减法,是不是表示从高地址向低地址方向移动指针了呢?释放也是同样的道理,一次加法操作又把 SP 恢复到初始状态。

再来看一下对 BP 寄存器的操作。

0x0013 00019 (test1.go:3)       MOVQ    BP, 48(SP) // 保存BP
0x0018 00024 (test1.go:3)       LEAQ    48(SP), BP // BP存放了新的地址
... ...
0x0046 00070 (test1.go:7)       MOVQ    48(SP), BP // 恢复BP的地址

这三行代码是不是感觉很变扭?写来写去让人云里雾里的。我先用文字描述一下,后面再用图来解释。

我们先做如下假设:此时 BP 指向的 是:0x00ff,48(SP) 的 地址 是:0x0008。
  • 第一条指令 MOVQ BP, 48(SP) 是把 0x00ff 写入到 48(SP)的位置;
  • 第二条指令 LEAQ 48(SP), BP 是更新寄存器指针,让 BP 保存 48(SP) 这个位置的地址,也就是 0x00ff 这个值。
  • 第三条指令 MOVQ 48(SP), BP ,因为一开始 48(SP) 保存了最开始 BP 的所存的值 0x00ff,所以这里是又把 BP 恢复回去了。

这几行代码的作用至关重要,正因为如此在执行的时候,我们才能找到函数开始的地方以及回到调用函数的位置,它才可以继续往下执行(如果觉得饶,先放过,后面有图,看完后再回来理解)。接着来看一下 returnTwo 函数。

returnTwo函数汇编

这里 NOSPLIT|ABIInternal, $0-32 说明,该函数的栈桢大小是0,由于有两个int参数,以及2个int返回值,合计为 4*8 = 32 字节大小,是不是跟上面的 main 函数对上了?。

这里有没有对 returnTwo 函数的栈桢大小是0表示迷惑呢?难道这个函数不需要栈空间吗?其实主要原因是:golang的参数传递与返回值都是要求使用栈来进行的(这也是为什么go能够支持多参数返回的原因)。所以参数与返回值所需空间都由 caller 来提供。

接下来,我们用完整的图来演示一下这个调用过程。

函数调用

这个图就画了将近1个小时,希望对大家理解有帮助。

整个的流程是:初始化 ----> call main function ----> call returnTwo function ----> returnTwo return ----> main return。

通过这张图,在结合我上面的文字解释,相信大家能够理解了。不过这里还有几个注意点:

  • BPSP 是寄存器,它保存的是栈上的地址,所以执行中可以对 SP 做运算找到下一个指令的位置;
  • 栈被回收 ADDQ $56, SP ,只是改变了 SP 指向的位置,内存中的数据并不会清空,只有下次被分配使用的时候才会清空;
  • callee的参数、返回值内存都是caller分配的;
  • returnTwo ret的时候,call returnTwo的next指令 所在栈位置会被弹出,也就是图中 0x0d00 地址所保存的指令,所以 returnTwo 函数返回后,SP 又指向了 0x0d08 地址。

由于上面涉及到一些 Plan9 的知识,就顺带一起介绍一些它的语法,如果直接讲语法会很枯燥,下面会结合一些实际中会用到的情况来介绍。既有收获又能学会语法。

Go的汇编plan9

我们整个程序的编译最终会被翻译成机器码,而汇编可以算是机器码的文本形式,他们之间可以一一对应。所以如果我们能够看懂汇编一点点就能够分析出很多实际问题。

开发go语言的都是当前世界最TOP的那群程序员,他们选择了持续装逼,不用标准的 AT&T 也不用 Intel 汇编器,偏要自己搞一套,没办法,谁让人家牛呢!Golang的汇编是基于 Plan9 汇编的,个人觉得要完全学懂太复杂了,因为这涉及到很多底层知识。不过如果只是要求看懂还是能够做到的。下面我们就举一些例子来试试看。

PS: 这东西完全学懂也没有必要,投入产出比太低了,对于一个应用工程师能够看懂就行。

在正式开始前,我们还是补充一些必要信息,上文已经涉及过一些,为了完整这里在整体介绍一下。

几个重要的伪寄存器:

  • SB:是一个虚拟寄存器,保存了静态基地址(static-base) 指针,即我们程序地址空间的开始地址;
  • NOSPLIT:向编译器表明不应该插入 stack-split 的用来检查栈需要扩张的前导指令;
  • FP:使用形如 symbol+offset(FP) 的方式,引用函数的输入参数;
  • SP:plan9 的这个 SP 寄存器指向当前栈帧的局部变量的开始位置,使用形如 symbol+offset(SP) 的方式,引用函数的局部变量,注意:这个寄存器与上文的寄存器是不一样的,这里是伪寄存器,而我们展示出来的都是硬件寄存器。

其它还有一些操作指令,根据名字多半都能够看出来,就不再介绍,直接开始干。

查看go应用代码对应的翻译函数

package main

func main() {
}

func test() []string {
    a := make([]string, 10)
    return a
}

--------

"".test STEXT size=151 args=0x18 locals=0x40
        0x0000 00000 (test1.go:6)       TEXT    "".test(SB), ABIInternal, $64-24 // 栈帧大小,与参数、返回值大小
        0x0000 00000 (test1.go:6)       MOVQ    (TLS), CX
        0x0009 00009 (test1.go:6)       CMPQ    SP, 16(CX)
        0x000d 00013 (test1.go:6)       JLS     141
        0x000f 00015 (test1.go:6)       SUBQ    $64, SP
        0x0013 00019 (test1.go:6)       MOVQ    BP, 56(SP)
        0x0018 00024 (test1.go:6)       LEAQ    56(SP), BP
        ... ...
        0x001d 00029 (test1.go:6)       MOVQ    $0, "".~r0+72(SP)
        0x0026 00038 (test1.go:6)       XORPS   X0, X0
        0x0029 00041 (test1.go:6)       MOVUPS  X0, "".~r0+80(SP)
        0x002e 00046 (test1.go:7)       PCDATA  $2, $1
        0x002e 00046 (test1.go:7)       LEAQ    type.string(SB), AX
        0x0035 00053 (test1.go:7)       PCDATA  $2, $0
        0x0035 00053 (test1.go:7)       MOVQ    AX, (SP)
        0x0039 00057 (test1.go:7)       MOVQ    $10, 8(SP)
        0x0042 00066 (test1.go:7)       MOVQ    $10, 16(SP)
        0x004b 00075 (test1.go:7)       CALL    runtime.makeslice(SB) // 对应的底层runtime function
        ... ...
        0x008c 00140 (test1.go:8)       RET
        0x008d 00141 (test1.go:8)       NOP
        0x008d 00141 (test1.go:6)       PCDATA  $0, $-1
        0x008d 00141 (test1.go:6)       PCDATA  $2, $-1
        0x008d 00141 (test1.go:6)       CALL    runtime.morestack_noctxt(SB)
        0x0092 00146 (test1.go:6)       JMP     0

根据对应的代码行数与名字,很明显的可以看到应用层写的 make 对应底层是 makeslice

逃逸分析

这里先说一下逃逸分析的概念。这里牵扯到栈、堆分配的问题。如果变量被分配到栈上,会伴随函数调用结束自动回收,并且分配效率很高;其次分配到堆上,则需要GC进行标记回收。所谓逃逸就是指变量从栈上逃到了堆上(很多人对这个概念都不清楚就在谈逃逸分析,面试遇到了好几次😓)。

package main

func main() {
}

func test() *int {
    t := 3
    return &t
}

------

"".test STEXT size=98 args=0x8 locals=0x20
        0x0000 00000 (test1.go:6)       TEXT    "".test(SB), ABIInternal, $32-8
        0x0000 00000 (test1.go:6)       MOVQ    (TLS), CX
        0x0009 00009 (test1.go:6)       CMPQ    SP, 16(CX)
        0x000d 00013 (test1.go:6)       JLS     91
        0x000f 00015 (test1.go:6)       SUBQ    $32, SP
        0x0013 00019 (test1.go:6)       MOVQ    BP, 24(SP)
        0x0018 00024 (test1.go:6)       LEAQ    24(SP), BP
        ... ...
        0x001d 00029 (test1.go:6)       MOVQ    $0, "".~r0+40(SP)
        0x0026 00038 (test1.go:7)       PCDATA  $2, $1
        0x0026 00038 (test1.go:7)       LEAQ    type.int(SB), AX
        0x002d 00045 (test1.go:7)       PCDATA  $2, $0
        0x002d 00045 (test1.go:7)       MOVQ    AX, (SP)
        0x0031 00049 (test1.go:7)       CALL    runtime.newobject(SB) // 堆上分配空间,表示逃逸了
        ... ...

这里如果是对 slice 使用汇编进行逃逸分析,并不会很直观。因为只会看到调用了 runtime.makeslice 函数,该函数内部其实又调用了 runtime.mallocgc 函数,这个函数会分配的内存其实就是堆上的内存(如果栈上足够保存,是不会看到对 runtime.makslice 函数的调用)。

实际go也提供了更方便的命令来进行逃逸分析:go build -gcflags="-m" ,如果真的是做逃逸分析,建议使用该命令,别折腾用汇编。

传值还是传指针

对于golang中的基本类型:字符串、整型、布尔类型就不多说了,肯定是值传递,那么对于结构体、指针到底是值传递还是指针传递呢?

package main

type Student struct {
    name string
    age  int
}

func main() {
    jack := &Student{"jack", 30}
    test(jack)
}

func test(s *Student) *Student {
    return s
}

-------

"".test STEXT nosplit size=20 args=0x10 locals=0x0
        0x0000 00000 (test1.go:14)      TEXT    "".test(SB), NOSPLIT|ABIInternal, $0-16
        ... ...
        0x0000 00000 (test1.go:14)      MOVQ    $0, "".~r1+16(SP) // 初始返回值为0
        0x0009 00009 (test1.go:15)      PCDATA  $2, $1
        0x0009 00009 (test1.go:15)      PCDATA  $0, $1
        0x0009 00009 (test1.go:15)      MOVQ    "".s+8(SP), AX // 将引用地址复制到 AX 寄存器
        0x000e 00014 (test1.go:15)      PCDATA  $2, $0
        0x000e 00014 (test1.go:15)      PCDATA  $0, $2
        0x000e 00014 (test1.go:15)      MOVQ    AX, "".~r1+16(SP) // 将 AX 的引用地址又复制到返回地址
        0x0013 00019 (test1.go:15)      RET

通过这里可以看到在go里边,只有值传递,因为它底层还是通过拷贝对应的值。

总结

今天的文章到此结束,本次主要讲了下面几个点:

  1. 计算机软硬资源之间的相互配合;
  2. Golang 编写的代码,函数与方法是怎么执行的,主要讲了栈上分配与相关调用;
  3. 使用 Plan9 分析了一些常见的问题。

希望本文对大家在理解、学习Go的路上有一些帮助。

参考资料

公众号-dayuTalk

查看原文

赞 23 收藏 17 评论 0

大愚Talk 分享了头条 · 2019-11-14

通俗易懂的把go的编译流程讲了出来。就算没有计算机知识也很容易接受。看完本文可以降低对go深入学习的恐惧感!

赞 0 收藏 15 评论 0

大愚Talk 发布了文章 · 2019-11-14

走进Golang之编译器原理

为了学好Golang底层知识装逼,折腾了一下编译器相关知识。下面的内容并不会提升你的生产技能点,但可以提高你的装逼指数。请按需阅读!


本文目录速览:

认识 go build

当我们敲下 go build 的时候,我们的写的源码文件究竟经历了哪些事情?最终变成了可执行文件。

这个命令会编译go代码,今天就来一起看看go的编译过程吧!

首先先来认识以下go的代码源文件分类

  • 命令源码文件:简单说就是含有 main 函数的那个文件,通常一个项目一个该文件,我也没想过需要两个命令源文件的项目
  • 测试源码文件:就是我们写的单元测试的代码,都是以 _test.go 结尾
  • 库源码文件:没有上面特征的就是库源码文件,像我们使用的很多第三方包都属于这部分

go build 命令就是用来编译这其中的 命令源码文件 以及它依赖的 库源码文件。下面表格是一些常用的选项在这里集中说明以下。

可选项说明
-a将命令源码文件与库源码文件全部重新构建,即使是最新的
-n把编译期间涉及的命令全部打印出来,但不会真的执行,非常方便我们学习
-race开启竞态条件的检测,支持的平台有限制
-x打印编译期间用到的命名,它与 -n 的区别是,它不仅打印还会执行

接下来就用一个 hello world 程序来演示以下上面的命令选项。

go的演示代码

如果对上面的代码执行 go build -n 我们看一下输出信息:

编译代码

来分析下整个执行过程

编译器编译过程

这一部分是编译的核心,通过 compilebuildidlink 三个命令会编译出可执行文件 a.out

然后通过 mv 命令把 a.out 移动到当前文件夹下面,并改成跟项目文件一样的名字(这里也可以自己指定名字)。

文章的后面部分,我们主要讲的就是 compilebuildid、link 这三个命令涉及的编译过程。

编译器原理

这是go编译器的源码路径

编译器流程

如上图所见,整个编译器可以分为:编译前端与编译后端;现在我们看看每个阶段编译器都做了些什么事情。先来从前端部分开始。

词法分析

词法分析简单来说就是将我们写的源代码翻译成 Token,这是个什么意思呢?

为了理解 Golang 从源代码翻译到 Token 的过程,我们用一段代码来看一下翻译的一一对应情况。

源码到token

图中重要的地方我都进行了注释,不过这里还是有几句话多说一下,我们看着上面的代码想象以下,如果要我们自己来实现这个“翻译工作”,程序要如何识别 Token 呢?

首先先来给Go的token类型分个类:变量名、字面量、操作符、分隔符以及关键字。我们需要把一堆源代码按照规则进行拆分,其实就是分词,看着上面的例子代码我们可以大概制定一个规则如下:

  1. 识别空格,如果是空格可以分一个词;
  2. 遇到 ()、'<'、'>' 等这些特殊运算符的时候算一个分词;
  3. 遇到 " 或者 数字字面量算分词。

通过上面的简单分析,其实可以看出源代码转 Token 其实没有非常复杂,完全可以自己写代码实现出来。当然也有很多通过正则的方式实现的比较通用的词法分析器,像 Golang 早期就用的是 lex,在后面的版本中才改用了用go来自己实现。

语法分析

经过词法分析后,我们拿到的就是 Token 序列,它将作为语法分析器的输入。然后经过处理后生成 AST 结构作为输出。

所谓的语法分析就是将 Token 转化为可识别的程序语法结构,而 AST 就是这个语法的抽象表示。构造这颗树有两种方法。

  1. 自上而下

这种方式会首先构造根节点,然后就开始扫描 Token,遇到 STRING 或者其它类型就知道这是在进行类型申明,func 就表示是函数申明。就这样一直扫描直到程序结束。

  1. 自下而上

这种是与上一种方式相反的,它先构造子树,然后再组装成一颗完整的树。

go语言进行语法分析使用的是自下而上的方式来构造 AST,下面我们就来看一下go语言通过 Token 构造的这颗树是什么样子。

go的AST树

这其中有意思的地方我全部用文字标注出来了。你会发现其实每一个 AST 树的节点都与一个 Token 实际位置相对应。

这颗树构造后,我们可以看到不同的类型是由对应的结构体来进行表示的。这里如果有语法、词法错误是不会被解析出来的。因为到目前为止说白了都是进行的字符串处理。

语义分析

编译器里边都把语法分析后的阶段叫做 语义分析,而go的这个阶段叫 类型检查;但是我看了以下go自己的文档,其实做的事情没有太大差别,我们还是按照主流规范来写这个过程。

那么语义分析(类型检查)究竟要做些什么呢?

AST 生成后,语义分析将使用它作为输入,并且的有一些相关的操作也会直接在这颗树上进行改写。

首先就是 Golang 文档中提到的会进行类型检查,还有类型推断,查看类型是否匹配,是否进行隐式转化(go没有隐式转化)。如下面的文字所说:

The AST is then type-checked. The first steps are name resolution and type inference, which determine which object belongs to which identifier, and what type each expression has. Type-checking includes certain extra checks, such as "declared and not used" as well as determining whether or not a function terminates.

大意是:生成AST之后是类型检查(也就是我们这里说的语义分析),第一步是进行名称检查和类型推断,签定每个对象所属的标识符,以及每个表达式具有什么类型。类型检查也还有一些其它的检查要做,像“声明未使用”以及确定函数是否中止。

Certain transformations are also done on the AST. Some nodes are refined based on type information, such as string additions being split from the arithmetic addition node type. Some other examples are dead code elimination, function call inlining, and escape analysis.

这一段是说:AST也会进行转换,有些节点根据类型信息进行精简,比如从算术加法节点类型中拆分出字符串加法。其它一些例子像dead code的消除,函数调用内联和逃逸分析。

上面两段文字来自 golang compile

这里多说一句,我们常常在debug代码的时候,需要禁止内联,其实就是操作的这个阶段。

# 编译的时候禁止内联
go build -gcflags '-N -l'

-N 禁止编译优化
-l 禁止内联,禁止内联也可以一定程度上减小可执行程序大小

经过语义分析之后,就可以说明我们的代码结构、语法都是没有问题的。所以编译器前端主要就是解析出编译器后端可以处理的正确的AST结构。

接下来我们看看编译器后端又有哪些事情要做。

机器只能够理解二进制并运行,所以编译器后端的任务简单来说就是怎么把AST翻译成机器码。

中间码生成

既然已经拿到AST,机器运行需要的又是二进制。为什么不直接翻译成二进制呢?其实到目前为止从技术上来说已经完全没有问题了。

但是,
我们有各种各样的操作系统,有不同的CPU类型,每一种的位数可能不同;寄存器能够使用的指令也不同,像是复杂指令集与精简指令集等;在进行各个平台的兼容之前,我们还需要替换一些底层函数,比如我们使用make来初始化slice,此时会根据传入的类型替换为:makeslice64 或者 makeslice。当然还有像painc、channel等等函数的替换也会在中间码生成过程中进行替换。这一部分的替换操作可以在这里查看

中间码存在的另外一个价值是提升后端编译的重用,比如我们定义好了一套中间码应该是长什么样子,那么后端机器码生成就是相对固定的。每一种语言只需要完成自己的编译器前端工作即可。这也是大家可以看到现在开发一门新语言速度比较快的原因。编译是绝大部分都可以重复使用的。

而且为了接下来的优化工作,中间代码存在具有非凡的意义。因为有那么多的平台,如果有中间码我们可以把一些共性的优化都放到这里。

中间码也是有多种格式的,像 Golang 使用的就是SSA特性的中间码(IR),这种形式的中间码,最重要的一个特性就是最在使用变量之前总是定义变量,并且每个变量只分配一次。

代码优化

在go的编译文档中,我并没找到独立的一步进行代码的优化。不过根据我们上面的分析,可以看到其实代码优化过程遍布编译器的每一个阶段。大家都会力所能及的做些事情。

通常我们除了用高效代码替换低效的之外,还有如下的一些处理:

  • 并行性,充分利用现在多核计算机的特性
  • 流水线,cpu有时候在处理a指令的时候,还能同时处理b指令
  • 指令的选择,为了让cpu完成某些操作,需要使用指令,但是不同的指令效率有非常大的差别,这里会进行指令优化
  • 利用寄存器与高速缓存,我们都知道cpu从寄存器取是最快的,从高速缓存取次之。这里会进行充分的利用

机器码生成

经过优化后的中间代码,首先会在这个阶段被转化为汇编代码(Plan9),而汇编语言仅仅是机器码的文本表示,机器还不能真的去执行它。所以这个阶段会调用汇编器,汇编器会根据我们在执行编译时设置的架构,调用对应代码来生成目标机器码。

这里比有意思的是,Golang 总说自己的汇编器是跨平台的。其实他也是写了多分代码来翻译最终的机器码。因为在入口的时候他会根据我们所设置的 GOARCH=xxx 参数来进行初始化处理,然后最终调用对应架构编写的特定方法来生成机器码。这种上层逻辑一致,底层逻辑不一致的处理方式非常通用,非常值得我们学习。我们简单来一下这个处理。

首先看入口函数 cmd/compile/main.go:main()

var archInits = map[string]func(*gc.Arch){
    "386":      x86.Init,
    "amd64":    amd64.Init,
    "amd64p32": amd64.Init,
    "arm":      arm.Init,
    "arm64":    arm64.Init,
    "mips":     mips.Init,
    "mipsle":   mips.Init,
    "mips64":   mips64.Init,
    "mips64le": mips64.Init,
    "ppc64":    ppc64.Init,
    "ppc64le":  ppc64.Init,
    "s390x":    s390x.Init,
    "wasm":     wasm.Init,
}

func main() {
    // 从上面的map根据参数选择对应架构的处理
    archInit, ok := archInits[objabi.GOARCH]
    if !ok {
        ......
    }
    // 把对应cpu架构的对应传到内部去
    gc.Main(archInit)
}

然后在 cmd/internal/obj/plist.go 中调用对应架构的方法进行处理

func Flushplist(ctxt *Link, plist *Plist, newprog ProgAlloc, myimportpath string) {
    ... ...
    for _, s := range text {
        mkfwd(s)
        linkpatch(ctxt, s, newprog)
        // 对应架构的方法进行自己的机器码翻译
        ctxt.Arch.Preprocess(ctxt, s, newprog)
        ctxt.Arch.Assemble(ctxt, s, newprog)

        linkpcln(ctxt, s)
        ctxt.populateDWARF(plist.Curfn, s, myimportpath)
    }
}

整个过程下来,可以看到编译器后端有很多工作需要做的,你需要对某一个指令集、cpu的架构了解,才能正确的进行翻译机器码。同时不能仅仅是正确,一个语言的效率是高还是低,也在很大程度上取决于编译器后端的优化。特别是即将进入AI时代,越来越多的芯片厂商诞生,我估计以后对这方面人才的需求会变得越来越旺盛。

总结

总结一下学习编译器这部分古老知识带给我的几个收获:

  1. 知道整个编译由几个阶段构成,每个阶段做什么事情;但是更深入的每个阶段实现的一些细节还不知道,也不打算知道;
  2. 就算是编译器这种复杂,很底层的东西也是可以通过分解,让每一个阶段独立变得简单、可复用,这对我在做应用开发有一些意义;
  3. 分层是为了划分指责,但是某些事情还需要全局的去做,比如优化,其实每一个阶段都会去做;对于我们设计系统也是有一定参考意义的;
  4. 了解到 Golang 对外暴露的很多方法其实是语法糖(如:make、painc etc.),编译器会帮我忙进行翻译,最开始我以为是go代码层面在运行时去做的,类似工厂模式,现在回头来看自己真是太天真了;
  5. 对接下来准备学习Go的运行机制、以及Plan9汇编进行了一些基础准备。

本文的很多信息都来自下面的资料。

公众号-dayuTalk

查看原文

赞 26 收藏 15 评论 4

大愚Talk 分享了头条 · 2019-06-24

图文并茂的介绍了https的链接过程。条理清晰,然人看了记忆深刻,并且能够与实际场景结合起来!

赞 0 收藏 2 评论 0

大愚Talk 发布了文章 · 2019-06-24

高并发架构的HTTP知识介绍

我们前面说过了 CDN的知识,也通过抓包分析了 TCP建立链接的过程。今天一起聊一聊应用层的协议 HTTP/HTTPS;这是应用工程师日常中接触最久的协议了。但是你真的了解他吗?

今天我们不讲 HTTP协议 的几种请求方式,主要介绍HTTP及HTTPS整个发送数据的过程。

消息结构

还记得前面讲的 DNS 的过程吗?通过DNS我们拿到了服务端的IP地址,然后通过TCP协议,完成了浏览器与应用服务器的连接建立。HTTP协议是建立在TCP协议之上的(上层协议必然依赖下层协议),连接建立后,自然是开始通信。那么通信的格式是什么呢?

blog-httpmsg01

看上面这张图,HTTP的请求与响应格式基本如此。我们分开来说。

对于 请求消息 ,由三部分构成:请求行、请求头、请求的Body;所谓的请求行,就是:POST / HTTP/1.1 这部分内容。接下来的就是请求头,也就是我们常说的HTTP头;然后换行后紧接着的内容就是请求的Body,也就是正儿八经发送给应用的参数。

对于 响应消息 ,也是由三部分构成:状态行、响应头、响应的Body;关于响应行就是标记本次请求获得的结果是什么,这里主要有:20X、30X、40X、50X这几个范围的状态码,需要熟记。响应头里边重要的主要有跟缓存相关的东西,这部分内容会知道浏览器、CDN等缓存体的缓存行为,需要有一定的了解;最后的实体就是你请求的想要的结构,比如:HTML、Json等等。

传输过程

消息构建后,如何发送进行传输呢?我们上面图片中看到的是字符串内容,HTTP本身是不能进行网络传输的,它必须依赖的底层的TCP协议建立的连接来发送数据。因此它实际上就是把这些构建好的字符串传给下层的TCP,至于TCP如何传输的可以看上篇文章,这里不展开了。

WebService 收到数据后会对数据进行处理然后交给应用服务器,应用服务器自然是将请求的Body作为输入,然后根据要求产生输出。输出的行为受到请求头中部分信息的控制,比如:格式(Content-Type)、编码(Accept-Charset)等。而产生的输出各个地方也会根据响应头进行处理。

看到这里大家有没有发现几个问题:HTTP依赖底层的TCP连接,也就是每个HTTP都需要进行三次握手,效率是不是会非常慢?这种方式总需要浏览器端主动发起链接,服务端想主动推送些什么很无能为力;

针对上面这些问题,HTTP2.0 协议也就诞生了,当然上面这些问题在 HTTP1.1 时代也有些解决方案。HTTP2.0 主要解决了协议头进行压缩,传输同样含义的内容,占用带宽更少速度更快;将上面的单向链接的方式改成二进制流的方式,服务端有能力主动推送数据;一个链接里边支持传输多种数据流。

关于 HTTP2.0 的内容不是文本主要想说的,大家可以自行了解下。接下来又到了 核心部分,关于 HTTPS 为什么安全、以及如何加密的解释。这部分内容算是面试的重要考点。

HTTPS为什么可靠

现在大网站基本都适用了HTTPS协议,那么它跟HTTP是什么关系呢?它其实就是HTTP加上TLS(SSL)安全层,合在一起就叫 HTTPS。为什么有了这层处理数据就安全了呢?

很简单,要想安全就得加密。加密的方式现在无非就是:对称加密非对称加密

对称加密: 加密与解密都是使用相同的密钥,因此这种方式加密数据,密钥一定不能丢失。

非对称加密: 有两把密钥,私钥与公钥。使用私钥加密的数据必须使用公钥进行解密,反之依然。

安全的代价

看起来 非对称加密 非常安全。不过对称加密的效率非常高。HTTPS正是综合使用这两种加密方式,让整个传输过程变得安全。接下来看看这个过程是如何完成的。

对称加密

我们先来看看,如果HTTPS只使用 对称加密,能否满足安全的需要呢?由于这种情况只有一个密钥,服务端怎么把这个密钥交给客户端呢?线上传输肯定会泄漏。

所以单单有对称加密是不能满足需求。看来得换个路子。

非对称加密

使用非对称加密的私钥加密数据,发给客户端。客户端用公钥解密就得到了数据。看起来好像没有什么问题。

但是这里有个问题,由于服务端发出来的数据是使用的私钥,由于公钥是公开,这相当于没有加密。大家都能够看到。并且服务端发出去的公钥这个过程也可能被串改啊,你怎么知道你收到的公钥就是服务器给你的呢?就跟现在很多诈骗公司一样,看起来有模有样,实则就是一皮包公司。

第三方公正

为了解决上述问题,出现了一个所谓的 CA 机构,它怎么解决这个信任问题呢?它将服务器的公钥放到 CA证书 里边传给客户端(这里指浏览器),浏览器拿到后验证一下这个证书是否真实有效,因为CA机构是有限可追溯的。就跟你的护照一样,可辨别真伪,所以CA证书证明了有效,那么CA证书中携带的公钥自然也证明了自己的身份。

是不是看起来整个过程非常麻烦?没有办法为了安全,这点代价非常值得。这也是为什么我们常常说HTTPS的效率略低于HTTP的原因。

工作模式

了解完上面的知识,我们来看看HTTPS到底是如何工作的?

blog-httpscomm02

  1. 客户端发起了一个https请求,包含版本信息,加密套件候选列表,压缩算法候选列表,随机数random_c,扩展字段等信息。这个过程此时是明文的。
  2. 然后服务器会进行回复,根据客户端支持的算法信息、套件等,服务器选择一个告诉客户端,我们就用这个吧,同时也会返回一个随机数random_s,后面协商密钥有用。
  3. 服务端响应客户端,这个响应中包含了证书的链接,用于交换密钥。
  4. 客户端对收到的数据进行检查,先把证书给拉下来,然后检查各种指标,如果不合法,会看到浏览器提醒不安全。

如果验证通过,就会生成一个 随机数字 Pre-master,并用证书公钥加密(非对称加密),发送给服务器。

  1. 此时的客户端已经有了生成证书的全部内容,它会计算协商的密钥(对称密钥),然后告诉服务端以后通信都采用协商的通信密钥和加密算法进行加密通信。紧接着会用协商的密钥加密一段数据发给服务端,看看是否能够正常。
  2. 上面这步里边,客户端发送了三个请求。服务器先将收到的 Pre-master 用自己的私钥解密出来。然后验证客户端用对称加密发过来的数据,如果通过,则也会告知客户端后续的通信都采用协商的密钥与算法进行加密通信。
  3. 并且服务端也会用对称加密生成一段加密信息给客户端让客户端试试(对称密钥)。
  4. 客户端使用对称密钥正确完成解密。握手结束。开始使用对称密钥的方式进行数据传输。

总结

由于不让文章显得过于复杂,我只介绍了最简单的单向认证。这种安全性并不是最高,单日常中也足够了。

本文从源头讲了为什么只有对称加密搞不定这件事;一步步演化出HTTPS的整个过程。

首先,为了效率,整个过程只采用了一次非对称加密来加密 Pre-master

其次,客户端、服务端分别使用了一次对称加密来进行密钥有效性的验证,来防止中间人攻击;

最后,也说了为什么整个过程需要CA机构的参与。

参考连接:

查看原文

赞 2 收藏 2 评论 0

大愚Talk 分享了头条 · 2019-05-07

结合实际的讲述了TCP协议,以及对照工作中常见问题分析了TCP带来的好处。避免了教科书式的灌输内容,文章的逻辑让你自然而然的理解TCP的设计与过程。

赞 0 收藏 14 评论 0

大愚Talk 发布了文章 · 2019-05-07

高并发架构的TCP知识介绍

做为一个有追求的程序员,不能只满足增删改查,我们要对系统全方面无死角掌控。掌握了这些基本的网络知识后相信,一方面日常排错中会事半功倍,另一方面日常架构中不得不考虑的高并发问题,理解了这些底层协议也是会如虎添翼。

本文不会单纯给大家讲讲TCP三次握手、四次挥手就完事了。如果只是哪样的话,我直接贴几个连接就完事了。我希望把实际工作中的很多点能够串起来讲给大家。当然为了文章完整,我依然会从 三次握手 起头。

再说TCP状态变更过程

不管是三次握手、还是四次挥手,他们都是完成了TCP不同状态的切换。进而影响各种数据的传输情况。下面从三次握手开始分析。

本文图片有部分来自网络,若有侵权,告知即焚

三次握手

来看看三次握手的图,估计大家看这图都快看吐了,不过为什么每次面试、回忆的时候还是想不起呢?我再来抄抄这过剩饭吧!
图片描述

首先当服务端处于 listen 状态的时候,我们就可以再客户端发起监听了,此时客户端会处于 SYN_SENT 状态。服务端收到这个消息会返回一个 SYN 并且同时 ACK 客户端的请求,之后服务端便处于 SYN_RCVD 状态。这个时候客户端收到了服务端的 SYN&ACK,就会发送对服务端的 ACK,之后便处于 ESTABLISHED 状态。服务端收到了对自己的 ACK 后也会处于 ESTABLISHED 状态。

经常在面试中可能有人提问:为什么握手要3次,不是2次或者4次呢?

首先说4次握手,其实为了保证可靠性,这个握手次数可以一直循环下去;但是这没有一个终止就没有意义了。所以3次,保证了各方消息有来有回就足够了。当然这里可能有一种情况是,客户端发送的 ACK 在网络中被丢了。那怎么办?

  1. 其实大部分时候,我们连接建立完成就会立刻发送数据,所以如果服务端没有收到 ACK 没关系,当收到数据就会认为连接已经建立;
  2. 如果连接建立后不立马传输数据,那么服务端认为连接没有建立成功会周期性重发 SYN&ACK 直到客户端确认成功。

再说为什么2次握手不行呢?2次握手我们可以想象是没有三次握手最后的 ACK, 在实际中确实会出现客户端发送 ACK 服务端没有收到的情况(上面的情况一),那么这是否说明两次握手也是可行的呢?
看下情况二,2次握手当服务端发送消息后,就认为建立成功,而恰巧此时又没有数据传输。这就会带来一种资源浪费的情况。比如:客户端可能由于延时发送了多个连接情况,当服务端每收到一个请求回复后就认为连接建立成功,但是这其中很多求情都是延时产生的重复连接,浪费了很多宝贵的资源。

因此综上所述,从资源节省、效率3次握手都是最合适的。话又回来三次握手的真实意义其实就是协商传输数据用的:序列号与窗口大小

下面我们通过抓包再来看一下真实的情况是否如上所述。

20:33:26.583598 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [S], seq 621839080, win 65535, options [mss 1460,nop,wscale 6,nop,nop,TS val 1050275400 ecr 0,sackOK,eol], length 0
20:33:26.660754 IP 103.235.46.39.80 > 192.168.0.102.58165: Flags [S.], seq 1754967387, ack 621839081, win 8192, options [mss 1452,nop,wscale 5,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,sackOK,eol], length 0
20:33:26.660819 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [.], ack 1754967388, win 4096, length 0

抓包:sudo tcpdump -n host www.baidu.com -S

  • S 表示 SYN
  • . 表示 ACK
  • P 表示 传输数据
  • F 表示 FIN

四次挥手

挥手,就是说数据传完了,同志们再见!

图片描述

这里有个问题需要注意下,其实客户端、服务端都能够主动发起关闭操作,谁调用 close() 就先发送关闭的请求。当然一般的流程,发起建立连接的一方会主动发起关闭请求(http中)。

关于4次挥手的过程,我就不多解释了,这里有两个重要的状态我需要解释下,这都是我亲自经历过的线上故障,close_waittime_wait

先给大家一个命令,统计tcp的各种状态情况。下面表格内容就来自这个命令的统计。

netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
Tcp状态连接数
CLOSE_WAIT505
ESTABLISHED808
TIME_WAIT3481
SYN_SENT1
SYN_RECV1
LAST_ACK2
FIN_WAIT22
FIN_WAIT11

大量的CLOSE_WAIT 这个在我之前的一篇文章 线上大量CLOSE_WAIT原因分析 已经有过介绍,它会导致大量的socket无法释放。而每个socket都是一个文件,是会占用资源的。这个问题主要是代码问题。它出现在被动关闭的一方(习惯称为server)。

大量的TIME_WAIT 这个问题在日常中经常看到,流量一高就出现大量的该情况。该状态出现在主动发起关闭的一方。该状态一般等待的时间设为 2MSL后自动关闭,MSL是Maximum Segment Lifetime,报文最大生存时间,如果报文超过这个时间,就会被丢弃。处于该状态下的socket也是不能被回收使用的。线上我就遇到这种情况,每次大流量的时候,每台机器处于该状态的socket就多达10w+,远远比处于 Established 状态的socket多的多,导致很多时候服务响应能力下降。这个一方面可以通过调整内核参数处理,另一方面避免使用太多的短链接,可以采用连接池来提升性能。另外在代码层面可能是由于某些地方没有关闭连接导致的,也需要检查业务代码。

上面两个状态一定要牢记发生在哪一方,这方便我们快速定位问题。

最后这里还是放上挥手时的抓包数据:

20:33:26.750607 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [F.], seq 621839159, ack 1754967720, win 4096, length 0
20:33:26.827472 IP 103.235.46.39.80 > 192.168.0.102.58165: Flags [.], ack 621839160, win 776, length 0
20:33:26.827677 IP 103.235.46.39.80 > 192.168.0.102.58165: Flags [F.], seq 1754967720, ack 621839160, win 776, length 0
20:33:26.827729 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [.], ack 1754967721, win 4096, length 0

不多不少,刚好4次。

TCP状态变更

网络上有一张TCP状态机的图,我觉得太复杂了,用自己的方式搞个简单点的容易理解的。我从两个角度来说明状态的变更。

  • 一个是客户端
  • 一个是服务端

看下面两张图的时候,请一定结合上面三次握手、四次挥手的时序图一起看,加深理解。

客户端状态变更

图片描述

通过这张图,大家是否能够清晰明了的知道 TCP 在客户端上的变更情况了呢?

服务端状态变更

图片描述

这一张图描述了 TCP 状态在服务端的变迁。

TCP的流量控制与拥塞控制

我们常说TCP是面向连接的,UDP是无连接的。那么TCP这个面向连接主要解决的是什么问题呢?

这里继续把三次握手的抓包数据贴出来分析下:

20:33:26.583598 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [S], seq 621839080, win 65535, options [mss 1460,nop,wscale 6,nop,nop,TS val 1050275400 ecr 0,sackOK,eol], length 0
20:33:26.660754 IP 103.235.46.39.80 > 192.168.0.102.58165: Flags [S.], seq 1754967387, ack 621839081, win 8192, options [mss 1452,nop,wscale 5,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,nop,sackOK,eol], length 0
20:33:26.660819 IP 192.168.0.102.58165 > 103.235.46.39.80: Flags [.], ack 1754967388, win 4096, length 0

上面我们说到 TCP 的三次握手最重要的就是协商传输数据用的序列号。那这个序列号究竟有些什么用呢?这个序号能够帮助后续两端进行确认数据包是否收到,解决顺序、丢包问题;另外我们还可以看到有一个 win 字段,这是双方交流的窗口大小,这在每次传输数据过程中也会携带。主要是告诉对方,我窗口是这么大,别发多了或者别发太少。

总结下,TCP的几个特点是:

  • 顺序问题,依靠序号
  • 丢包问题,依靠序号
  • 流量控制,依靠滑动窗口
  • 拥塞控制,依靠拥塞窗口+滑动窗口
  • 连接维护,三次握手/四次挥手

顺序与丢包问题

这个问题其实应该很好理解。由于数据在传输前我们已经有序号了,这里注意一下这个序号是随机的,重复的概率极地,避免了程序发生乱入的可能性。

由于我们每个数据包有序号,虽然发送与到达可能不是顺序的,但是TCP层收到数据后,可以根据序号进行重新排列;另外在这个排列过程中,发现有了1,2,3,5,6这几个包,一检查就知道4要么延时未到达,要么丢包了,等待重传。

这里需要重要说明的一点是。为了提升效率,TCP其实并不是收到一个包就发一个ack。那是如何ACK的呢?还是以上面为例,TCP收到了1,2,3,5,6这几个包,它可能会发送一个 ack ,seq=3 的确认包,这样次一次确认了3个包。但是它不会发送 5,6 的ack。因为4没有收到啊!一旦4延时到达或者重发到达,就会发送一个 ack, seq=6,又一次确认了3个包。

流量控制与拥塞控制

这两个概念说实话,让我理解了挺长时间,主要是对它们各自控制的内容以及相互之间是否有作用一直没有闹清楚。

先大概说下:

  • 流量控制:是根据接收方的窗口大小来感知我这次能够传多少数据给对方;———— 滑动窗口
  • 拥塞控制:而拥塞控制主要是避免网络拥塞,它考虑的问题更多。根据综合因素来觉得发多少数据给对方;———— 滑动窗口&拥塞窗口

举个例子说下,比如:A给B发送数据,通过握手后,A知道B一次可以收1000的数据(B有这么大的处理能力),那么这个时候滑动窗口就可以设置成1000。那是不是最后真的可以一次发这么多数据给B呢?还不是,这时候得问问拥塞窗口,老兄,现在网络情况怎么样?一次运1000的数据有压力吗?拥塞窗口一通计算说不行,现在是高峰期,最多只能有600的货上路。最终这次传数据的时候就是 600 的标注。大家也可以关注抓包数据的 win 值,一直在动态调整。

当然另外一种情况是滑动窗口比拥塞窗口小,虽然运输能力强,但是接收能力有限,这时候就要取滑动窗口的值来实际发生。所以它们二者之间是有关系的。

所以具体到每次能够发送多少数据,有这么一个公式:

LastByteSend - LastByteAcked <= min{cwnd,rwnd}
  • LastByteSend 是最后一个发送的字节的序号
  • LastByteAcked 最后一个被确认的字节的序号

这两个相减得到的是本次能够发送的数据,这个数据一定小于或等于 cwnd 与 rwnd 中最小的一个值。相信大家能够理清楚。

那么这部分知识对于实际工作中有什么作用呢?指导意义就是:如果你的业务很重要、很核心一定不要混布;二是如果你的服务忽快忽慢,而确信依赖服务没有问题,检查下机器对应的网络情况;三是窗口这个速度控制机制,在我们进行服务设计的时候,非常具有参考意义。是不是有点消息队列的感觉?(很多消息队列都是匀速的,我们是否可以加一个窗口的概念来进行优化呢?)

是什么限制了你的连接

到了最关键的地方了,精华我都是留到最后讲。下面放一张网上找的socket操作步骤图,画的太好了我就直接用了。
图片描述

我们假设我的服务端就是 Nginx ,我来尝试解读一下。当客户端调用 connect() 时候就会发起三次握手,这次握手的时候有几个元素唯一确定了这次通信(或者说这个socket),[源IP:源Port, 目的IP:目的Port] ,当然这个socket还不是最终用来传输数据的socket,一旦握手完成后,服务端会在返回一个 socket 专门用来后续的数据传输。这里暂且把第一个socket叫 监听socket,第二个叫 传输socket 方便后文叙述。

为什么要这么设计呢?大家想一想,如果监听的socket还要负责数据的收发,请问这个服务端的效率如何提升?什么东西、谁都往这个socket里边丢,太复杂!

提高连接常用套路

到了这一步,我们现在先停下来算算自己的服务器机器能够有多少连接呢?这个极限又是如何一步步被突破呢?

先说 监听socket ,服务器的prot一般都是固定的,服务器的ip当然也是固定的(单机)。那么上面的结构 [源IP:源Port, 目的IP:目的Port] 其实只有客户端的ip与端口可以发生变化。假设客户端用的是IPv4,那么理论连接数是:2^32(ip数) * 2^16(端口数) = 2^48。

看起来这个值蛮大的。但是真的能够有这么多连接吗?不可能的,因为每一个socket都需要消耗内存;以及每一个进程的文件描述符是有上限的。这些都限制了最终的连接数。

那么如何进行调和呢?我知道的操作有:多进程、多线程、IO多路服用、协程等手段组合使用。

多进程

也就是监听是一个进程,一旦accept后,对于 传输socket 我们就fork一个新的子进程来处理。但是这种方式太重,fork一个进程、销毁一个进程都是特别费事的。单机对进程的创建上限也是有限制的。

多线程

线程比进程要轻量级的多,它会共享父进程的很多资源,比如:文件描述符、进程空间,它就是多了一个引用。因此它的创建、销毁更加容易。每一个 传输socket 在这里就交给了线程来处理。

但是不管是多进程、还是多线程都存在一个问题,一个连接对应一个进程或者协程。这都很难逃脱 C10K 的问题。那么该怎么办呢?

IO多路复用

IO多路复用是什么意思呢?在上面单纯的多进程、多线程模型中,一个进程或线程只能处理一个连接。用了IO多路复用后,我一个进程或线程就能处理多个连接。

我们都知道 Nginx 非常高效,它的结构是:master + worker,worker 会在 80、443端口上来监听请求。它的worker一般设置为 cpu 的cores数,那么这么少的子进程是如何解决超多连接的呢?这里其实每个worker就采用了 epoll 模型(当然IO多路复用还有个select,这里就不说了)。

处于监听状态的worker,会把所有 监听socket 加入到自己的epoll中。当这些socket都在epoll中时,如果某个socket有事件发生就会立即被回调唤醒(这涉及epoll的红黑树,讲不清楚不细说了)。这种模式,大大增加了每个进程可以管理的socket数量,上限直接可以上升到进程能够操作的最大文件描述符。

一般机器可以设置百万级别文件描述符,所以单机单进程就是百万连接,epoll是解决C10K的利器,很多开源软件用到了它。

这里说下,并不是所有的worker都是同时处于监听端口的状态,这涉及到nginx惊群、抢自旋锁的问题,不再本文范围内不多说。

关于ulimit

在文章的最后,补充一些单机文件描述符设置的问题。我们常说连接数受限于文件描述符,这是为什么?

因为在linux上一切皆文件,故每一个socket都是被当作一个文件看待,那么每个文件就会有一个文件描述符。在linux中每一个进程中都有一个数组保存了该进程需要的所有文件描述符。这个文件描述符其实就是这个数组的 key ,它的 value 是一个指针,指向的就是打开的对应文件。

关于文件描述符有两点注意:

  1. 它对应的其实是一个linux上的文件
  2. 文件描述符本身这个值在不同进程中是可以重复的

另外补充一点,单机设置的ulimit的上线受限与系统的两个配置:

fs.nr_open,进程级别

fs.file-max,系统级别

fs.nr_open 总是应该小于等于 fs.file-max,这两个值的设置也不是随意可以操作,因为设置的越大,系统资源消耗越多,所以需要根据真实情况来进行设置。


至此,本篇长文就完结了。这跟上篇 高并发架构的CDN知识介绍 属于一个系列,高并发架构需要理解的网络基础知识。

后面还会写一下 HTTP/HTTPS 的知识。然后关于高并发网络相关的东西就算完结。我会开启下一个篇章。


如果你想对网络协议了解更多,推荐一个课程:
图片描述

查看原文

赞 20 收藏 14 评论 0