Post

记录NCX开发的路程

记录NCX开发的路程

相关技术的学习

Linux Server Structure

epoll 多路复用

Linux中的多路复用方案有三种:select、poll和epoll。epoll的效率是最高的,这里详细讲解epoll。

epoll相关的系统调用有三个:

  • epoll_create
  • epoll_ctl
  • epoll_wait

给两张图,你能够知道socket在内核中到底是如何组织的。

alt text

alt text

然后你在看看Linux源码中accept是如何创建socket和fd并绑定在一起的。

alt text

初始化socket对象

在系统调用accept中,会调用sock_alloc申请一个socket对象,然后把listen状态的socket的协议操作函数复制到新的socket对象中。因为inetops方法是一样的。然后就要为新的socket对象分配file,socket结构体中有个重要的成员,file指针。初始化file的时候,会把socket的file_ops函数赋给file的f_op。因为read、write、poll、release也是可以一一对应上sock_opt 内的函数的。

socket对象内有另外一个重要的成员,那就是sock。accept调用会调用到socket的ops的accept方法,内部会调用到inet_accept方法,会把sock的全连接队列中拿出一个连接态的sock,复制给newsock内的sock成员。

socket、sock、file三者创建完成后,就要把file注册进打开的文件列表,这个时候fd就可以查到这个file了。

alt text

epoll_create的实现

用户进程调用epoll_create,内核会创建一个eventpoll的对象,同样要把它关联进file里。过程和socket的关联过程类似。

eventpoll的结构如下:

alt text

有三个重要的成员:

  • wait_queue_head_t wq / 等待队列:软中断数据继续的时候会通过wq来找到阻塞在epoll对象上的用户进程
  • list_head rdllist / 就绪队列 : 就绪的描述符的列联表。当有连接就绪时,内核会把就绪的连接放入链表中
  • rb_root_t rbr / epitem红黑树根节点 : 用来快速操作用户进程添加进来的所有的socket连接

然后完成各自的初始化工作。

epoll_ctl

理解ctl发生了什么是理解epoll的关键。

为了简单期间,这里之讲解epoll_add具体发生了什么。

当我们调用epoll_add去添加socket时,内核会进行三个操作:

  • 初始化一个红黑树节点epitem
  • 添加epollevent到等待时间中,epollevent会自定义用户感兴趣的epoll事件,以及特定对象。
  • 将epitem添加到红黑树中

这些数据结构的大致结构如下:

alt text

在epoll_ctl中,会找到需要的eventpoll对象、file对象,然后调用ep_insert函数,所有注册工作是在这个函数中完成的。

alt text

ep_insert中会创建,epitem对象并设置其参数。epitem的结构如下:

alt text

创建完epitem并完成初始化后,ep_insert需要设置socket对象上的等待队列任务, 并把函数ep_poll_callback设置为数据就绪时候的回调函数。这个回调函数是在内核中被初始化好的。那么如何设置ep_poll_callback为数据就绪时的回调函数呢?在ep_ptable_proc函数中,新建了等待队列项,并将其回调函数注册为ep_poll_callback,然后将这个等待项添加到socket的等待队列中。这个ep_ptable_proc函数会被注册进init_poll_funcptr。

软中断将数据收到socket的接收队列后,就会调用注册的ep_poll_call来通知epoll对象。

最后将分配的epitem插入到红黑树中。为什么要插入到红黑树呢?论效率不是哈希表的查找最快吗。这是因为epoll要在查找、插入、内存开销上的多个方面比较均衡,最终选择了红黑树。

epoll_wait等待接收

它被调用的时候就只需要查看自己的eventpoll对象内的就绪队列是否有数据即可。如果有就返回,没有就创建一个等待队列项关联到当前进程,添加到eventpoll的等待队列上,然后把自己阻塞调就完事。

alt text

注意这里创建的等待队列和epoll_ctl add的时候创建的等待队列不一样。前者是挂在epoll对象上的,后者是挂在socket对象上的。这个等待队列项的回调函数是default_wake_function,这个函数会唤醒等待队列项上的进程。

重点!数据此刻到来!我们要知道唤醒的全过程!

alt text

在这一节,将看到软中断是如何在数据处理完之后依次进入各个回调函数,最后通知用户进程的。看完这里想必你也能明白epoll调用所干的事的意义。

软中断是如何处理网卡的帧的这里不做过多的讲解。直接进入主题,从tcp协议栈开始。

tcp协议栈处理到来的数据包的入口函数是tcp_v4_do_rcv。

alt text

tcp_v4_do_rcv会根据不同的tck状态来处理数据包,这里直接来看established状态下的处理。也就是tcp_rvc_established函数。里面在完成对数据的各个处理后,就把数据推入到socket的接收队列中。然后调用sk->sk_data_ready函数,调用socket注册的等待队列项的回调函数,也就是调用sock_def_readable函数。这个函数后续会调用到epoll_ctl注册在等待队列项上注册的回调函数,也就是ep_poll_callback。具体怎么调用到ep_poll_callback,重点是sock_def_readable内的wake_up_interruptible_sync_poll函数。

alt text

alt text

然后进入_wake_up_common

alt text

这里就会将等待队列内的所有等待项都执行回调函数。

执行ep_poll_callback

alt text

在ep_poll_callback中会根据等待队列项中的base指针找到epitem,进而找到eventpoll对象。

然后将自己的epitem插入到eventpoll的就绪队列中。

然后会查看eventpoll的等待队列中的等待项,有的话就调用等待项内的回调函数,这里就是把wait调用阻塞的进程给唤醒了。

epoll被唤醒

将epoll_wait阻塞的进程重新推入就绪队列,等待内核重新调度进程。然后epoll_wait会恢复执行,将rdlisst中就绪的事件返回给用户进程,就是就绪的epitem。

Epoll线程池

和传统的线程池不同,epoll线程池的设计是将epoll循环wait任务和io处理函数绑定在用一个线程中,这样的好处是可以利用多个线程来异步处理多个连接的IO操作。线程池启动时会将所有epoll线程创建并将内部的epoll wait循环启动。

外界向epoll线程池申请sub reactor来注册监听的文件描述符时,会轮询分配每个sub reactor。这里是需要被优化的,不然就会导致有些sub reactor分配到过多的监听对象,需要处理过多的IO读写事件。

Epoll线程

Epoll线程的loop对象会在线程启动后被初始化,线程方程的底层是循环调用epoll_wait去不断监听到来的事件。事件到来调用回调函数处理事件。

Hanlder – channel

Channel的初始化需要充当reator的epoll对象和处理事件的fd。那么如何通过Channel去向reactor注册监听事件以及回调函数呢?channel完成回调函数初始化后,会向reactor注册fd和指向自身的指针。在EpollItem结构体中有个data对象,他不仅可以是fd还可以是一个ptr裸指针,指向channel后,在epollwait返回就绪的epollitem时会调用channel的事件处理函数。

事件处理函数

handle_event函数会根据到来事件为读事件还是写事件,分别调用对应的读写处理函数。读写调用处理函数可以通过Server结构体开放给用户自己定义。

注意读写事件处理函数不是一被初始化就不能发生变化的,但是在修改读写事件处理函数之前,要先在合适的时机关闭channel,取消对epoll的监听,在修改完成后再重新开启channel,防止后面到来的数据被老的处理函数处理。

Reactor模式

Reactor的翻译是反应堆,顾名思义,就是对事件的反应,也就是有事件incoming,Reactor就会对其做出反应,将事件分配给可用的进程进行使用。因此Reactor模式由两个部分组成:

  • Reactor:负责监听事件,当事件到来时,将事件分发给对应的处理程序。也就是观察者
  • Handler:负责处理资源池处理事件

给张reactor模型的示意图:

alt text

多Reactor多线程模式

alt text

这里的多reactor指的是一主多从Reactor的模式。Main Reactor的作用是一个acceptor的作用,负责处理服务端socket的fd上的poll的事件,当服务端socket的fd上有事件是,一般是连接到来。Main Reactor调用回调函数处理新连接,这个回调函数会accept并把连接封装成连接体交给Server处理。

处理到来的连接时,Server回想Epoll线程池中申请一个线程来注册这个连接体。并且完成连接体的初始化工作后,比如读写回调函数的设置,然后开启通道,允许对读写监听。

断开连接 && Connection连接体生命周期管理

当客户端断开连接时,会向服务端socket发送FIN包,服务端接收到FIN包后进行数据处理,socket状态转换为CLOSE_WAIT,然后将阻塞的监听该fd的epollwait唤醒,处理读事件调用read函数,返回0,表示对端关闭连接,服务端关闭连接,释放资源。但是存在一个问题,就是在服务器调用连接体的close函数之前,需要保证连接体的读写事件处理完毕,不然在可能会访问到已经释放的资源导致内存泄露。为了避免这个问题,使用share_ptr来管理连接体的生命周期。在调用读写事件处理函数时,持有对连接体的共享所有权,当读写事件处理完后在连接体的引用计数为0时,释放连接体。

使用unordered_map管理资源多线程安全问题(争取要引导面试官到这个问题上!!!)

背景:对于unordered_map的读写操作是线程不安全的,所以对于unordered_map的读写操作不能被多线程同时读写,否则线程会崩溃进而导致进程崩溃。为了解决这个问题,要么加锁保证线程安全,要么将所有对unordered_map的读写操作放到一个线程中。

这里采用的是第二种方案,由于子进程只会在调用释放连接体的时候操作到map,所以把这个操作map的动作用函数bind起来,然统一在主线程中进行。

具体是如何实现的呢?由于每个线程都有个调用epollwait的while循环,所以从epollwait返回的时候一定位于本线程,可以在线程每次从epollwait返回时,把一个待调用函数中的任务全部执行。在调用添加函数(添加任务函数的执行进程不一定是当前线程,因为)的时候先看看当前是否位于当前线程,是就直接执行,不是就添加到待执行任务队列等待执行。

但是这样就又有个问题,就是epollwait如果被一直阻塞,那么添加进来的任务就不会被唤醒。我们希望在某个时刻能够主动唤醒wpollwait,这就需要下面的机制。

epollwait 主动唤醒机制

这里介绍一下eventfd。eventfd是Linux内核提供的在多个线程或进程之间通知事件发生的机制。eventfd也能够被epoll监听,所以我们在epoll初始化的时候就添加监听,然后回调函数就是消费掉向eventfd写入的数据,这样就可以主动的从epoll唤醒。

NAT && 内网穿透 concept

NAT的存在划分出了内外网,内网的ip想要和公网服务建立连接,必须由路由器进行nat映射,将私有的IP地址转换为公有的IP地址;这种转换在路由器上进行,在TCP的情况下,建立连接的TCP连接首次握手时的SYN包一经发出就会在表中生成对应的映射表项。

但是这种方式存在限制,就是内网的设备可以访问外网,但外网的设备不能访问内网,更别谈别的内网设备访问我的内网设备了。但是跨内网访问在很多场景下是有需求的,比如远程桌面、远程连接办公室电脑等等,都是需要跨内网访问。实际上这种技术称为内网穿透。

内网穿透的技术的实现方式多种多样,我给出ncx所应用的内网穿透原理。最核心的理念是两个流的双向传播。具体是怎么实现的呢?现在假设我们有一台公网云服务器,上面跑着内网穿透的服务,然后在内网中,跑着个内网服务,内网中还有个内网穿透客户端连接着公网的服务端。服务端和客户端会事先协商好内网服务代理的端口,就是外网访问云服务器上的哪个端口就可以把连接穿透到内网的某个服务上。

现在假设有个外界有个连接连接上云服务器的代理端口,服务器监听到后,会像客户端发送消息,说有连接到来,然后这个客户端就会重新同时和内网穿透服务端和私网服务端都发起连接。这样在服务端有两个Stream(分别连接着外界和内网客户端),在客户端有两个Stream(分别连接公网服务端和私网服务端)。其实这个时候工作就完成的差不多了,加下来就是要在这两个流对体之间进行数据的双向转发。就是StreamA收到数据后会发送给StreamB,StreamB收到数据后会发送给StreamA。这样整个转发链条就打通了,内网穿透服务也就建立完成了。

debug和思考记录

1 Stream代理技术

场景假设

现在有个socket在ip1下listen,我们希望访问ip2来连接到ip1的socket。这个过程就是Stream代理过程。那么如何实现这一过程呢?下面将详细介绍!

Stream转发思想

当ip1有socket listen时,通知ip1主动connect到ip2,通知ip2:ip1有listen的socket,可以开始等待连接。这时ip2会创建socket开始监听指定的端口,等待访问ip2的连接。当ip2有连接时,将accept ip1的Stream和accept 外界ip的Stream进行双向流交换,知道外界ip或者ip1的Stream关闭,这时Stream代理结束。以上就是Stream代理的基本思想。

Stream双向流交换

存在需要交换的两个Stream,Stream1 read的数据会write到Stream2,Stream2 read的数据会write到Stream1。直到Stream1或者Stream2关闭,这时双向流交换结束。

2 客户端发送数据时、服务端登记的监听客户端的fd会被一直唤醒

epoll唤醒后,fd内的数据需要被读取,不然会一直识别到可读信号导致epollwait一直返回该事件

3 Stream 双向流数据传输

设计 exchannel

4 Server 调用read时,会一直阻塞

accept调用返回的fd不会因为对端client socket设置为非阻塞而是非阻塞,需要我们手动设置为非阻塞

5 channel update 的时机

注意每次更新完channel的callback函数后,需要调用channel的update函数,这样才能使callback函数生效,为了防止遗忘,可以在setcallback函数中调用update函数。

开发日记

2024-11-30

今天完成了ncx server的底层架构,ncx server的底层架构的设计主要参考了一个名叫TinyLinuxServer的开源项目。架构设计是这样子的。首先我们要并发的处理客户端请求,并发的处理读写需求,而这些事件的到来可以统一为向文件描述符进行读写,linux提供多路复用技术,通过对文件描述符的监听,当文件描述符有指定事件发生时,会通知并进行回调。linux提供的多路复用技术有三种,分别是select系、poll系、epoll系。epoll系相较于前面两种的思想更为先进,所以这里我们使用epoll调用作为服务器异步并发处理请求的基础。

2024-12-09

前面几天由于课程的答辩任务繁多,耽误了开发进度。今天重新拾起,并且完成了一对连接体的双向数据交换,这个对于内网穿透的实现是最基本的。 exchange 交换的实现非常简单,就是对连接体的读回调函数进行重新定义,定义为读后转发。下一步的目标是实现动态的连接体匹配,我希望通过消息队列来实现这一需求。

这天下午马不停蹄的完成zeromq的多生产者单消费者channel的设计,其原理是在消费者处使用bind绑定,在生产者处使用connect连接。zeromq是非常轻量级、高性能的异步消息队列库,在多线程、网络场景中经常使用。

2025-01-13 连接池设计

This post is licensed under CC BY 4.0 by the author.