八股文-项目
DragonOS
syscall alarm
q1:什么是syscall alarm?
alarm系统调用是我加入dragonos社区领到的第一个任务,算是个新手任务。负责实现alarm系统调用。alarm系统调用的作用就是内核申请一个定时器alarm,在经过一段时间后向用户程序发送一个SYSALATM信号,用户程序捕捉到这个信号后执行提前设定号的回调函数。
这个alarm结构体注册在用户进程的PCB中,一个用户进程就只有一个alarm(在dragonos里目前的实现是这个样子的。alarm结构体的设计是这样子的,alarm由定时器和定时器触发时间组成。定时器由触发时间戳和回调函数组成。回调函数的作用是向目标进程发送alarm信号。
设定好定时器,然后激活定时器,把定时器插入到定时器链表里,链表按照定时器触发时间的先后排序。这里简单说下系统定时器的原理。就是向中断向量注册中断号timer和中断处理程序。中断处理为找定时器链表中过时的定时器,运行他们的回调函数。
下面再讲一下信号机制。TODO
Loopback
Loopback网卡和网卡驱动的编写是我在dragonos社区内接到的第二个任务,这个任务相比较于上一个任务有一些难度。首先在我开始设计前,我需要了解dragonos现有的设备驱动模型。
可以围绕几个trait来给面试官介绍,正好也可以体现一下rust的先进性。
dragonos的设备驱动模型是这样的,首先由设备Device trait和驱动Driver trait构成基本元素,rust的特征trait可以定义共享行为,将所有设备和驱动都需要使用的具有相似性的函数设置成trait 接口里,所有的设备和驱动都需要去自定义这些函数。就类似于adapt适配器设计模式。Device接口负责提供一些基本的设备信息就拿我写的loopback网卡来说,向设备类型,设备名,设备驱动等等。Driver接口负责将设备加入到驱动中,额由于Lo设备是单设备和单驱动,所以就不需要为驱动实现这个接口。然后在自己的设备和驱动的实现中需要自定义上述接口。
然后作为网络设备,也有一些抽象出来的接口需要去实现。dragonos内的网络设备模型需要你实现两个接口,一个网卡设备接口和网络协议栈接口。网卡设备接口的作用首先是获取网卡基本信息和网卡状态,像网卡名,mac地址,然后每个网卡都需要有poll函数,供中断到来时,内核去使用网卡。网卡的poll函数本质上是一个中断处理函数。然后就是网络协议层接口,由于网络协议栈是非常复杂的,为了实现短期上线网络功能,dragonos内部现在是使用一个叫smoltcp的开源的网络协议栈,像阿里的星链操作系统也同样是使用那个协议栈。为了减小协议栈和网络设备的耦合,smoltcp定义了需要网卡实现的接口函数,也是为driver实现的trait,主要包括网卡的收发包函数。这里的接收的数据包和发送的数据包需要网卡根据接收的数据去创建,需要去定义它们的消费函数。
介绍完模型后,就到了重点的地方,实现lo设备及其驱动。lo设备叫本地环回设备,向lo设备发送的数据包从传输层下来到网络层,发送到lo设备的缓冲区。lo的缓冲区实现为一个队列,发送的数据包从队尾进入,从队头获取接收到的数据包。这样就能实现发包即收包,实现本地环回。同时还为队列设置一个capacity,模拟丢包。
lo设备的驱动就是自定义网络协议栈需要实现的接口函数,就是设备的收发包函数。就是创建数据包,返回给网络栈消费。
lo发包的时候就直接写到缓冲队列,然后用户调用recv函数去收包。以上就是我实现lo网卡的全部过程
Unix socket
由于wxg面试踩的坑,我要在这里好好理清楚一下dragon os的socket在内核是怎么组织的。参照Linux中万物皆文件的思想,dragonos内的socket本质上也是一个文件。dragonos内有一个虚拟的文件系统vfs,他不挂载在任何目录下,也没有和什么磁盘挂钩,就是一个存在在内存中的虚拟的文件系统,用来作内核中所有文件系统的适配器。所有实现vfs要求的trait(叫indexnode,需要自定义一些基本的文件操作)的结构体(inode)都可以成为一个文件并挂载进vfs,并得到对应的fd。后续用户可以通过这个fd来找到这个inode对象。
然后内核中的socket被设计为trait,可以这么理解,实现了这个socket trait的结构体就是socket文件。然后上面的inode内又包含着这个socket。然后你就可以通过这个inode来使用这个socket的操作,像bind、connect、这些常见的socket调用。
时间短的时候就直接讲这个!不要上面的铺垫。
unix socket 还有个难点,就是要把这个inode和文件地址关联起来。为什么说需要这个呢?假设现在有个客户端和服务端,客户端想要去连接服务端就需要服务端的地址,所以就有这么个需求。我们知道,在unix中,bind的对象是一个具体的文件,你需要通过这个服务端bind的文件地址去找到包含socket的inode结构体,但是服务端的inode在调用bind之前就已经存在了。所以比较麻烦,我们现在的设计是一个比较折中的设计,就是服务端调用bind的时候,会拿着bind的地址去文件系统中查找这个文件的inode信息,得到他的inodeid,然后拿着这个id和socket进行一个哈希映射。这样后面就能够通过这个文件地址来找到这个socket了。
介绍下unix socket的原理
NCX
Linux Server Structure
epoll 多路复用
Linux中的多路复用方案有三种:select、poll和epoll。epoll的效率是最高的,这里详细讲解epoll。
epoll相关的系统调用有三个:
- epoll_create
- epoll_ctl
- epoll_wait
给两张图,你能够知道socket在内核中到底是如何组织的。
然后你在看看Linux源码中accept是如何创建socket和fd并绑定在一起的。
初始化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了。
epoll_create的实现
用户进程调用epoll_create,内核会创建一个eventpoll的对象,同样要把它关联进file里。过程和socket的关联过程类似。
eventpoll的结构如下:
有三个重要的成员:
- 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添加到红黑树中
这些数据结构的大致结构如下:
在epoll_ctl中,会找到需要的eventpoll对象、file对象,然后调用ep_insert函数,所有注册工作是在这个函数中完成的。
ep_insert中会创建,epitem对象并设置其参数。epitem的结构如下:
创建完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的等待队列上,然后把自己阻塞调就完事。
注意这里创建的等待队列和epoll_ctl add的时候创建的等待队列不一样。前者是挂在epoll对象上的,后者是挂在socket对象上的。这个等待队列项的回调函数是default_wake_function,这个函数会唤醒等待队列项上的进程。
重点!数据此刻到来!我们要知道唤醒的全过程!
在这一节,将看到软中断是如何在数据处理完之后依次进入各个回调函数,最后通知用户进程的。看完这里想必你也能明白epoll调用所干的事的意义。
软中断是如何处理网卡的帧的这里不做过多的讲解。直接进入主题,从tcp协议栈开始。
tcp协议栈处理到来的数据包的入口函数是tcp_v4_do_rcv。
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函数。
然后进入_wake_up_common
这里就会将等待队列内的所有等待项都执行回调函数。
执行ep_poll_callback
在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模型的示意图:
多Reactor多线程模式
这里的多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。这样整个转发链条就打通了,内网穿透服务也就建立完成了。
CPP Modern Usage
ZeroMQ 实现的多生产者单消费者通道
ZeroMQ是一个消息队列的库,它提供了一些socket的封装,可以让你在不同的进程间进行通信。ZeroMQ的一个特点是它的socket是可以跨语言的,也就是说你可以在不同的语言中使用ZeroMQ的socket进行通信。ZeroMQ的socket有很多种类型,比如REQ、REP、PUB、SUB、PUSH、PULL等等。这里我介绍一种ZeroMQ的使用方式,就是多生产者单消费者通道。
多生产者单消费者通道,接收端bind,发送端connect,可以实现多个发送者像单个消费者发送消息。接收端的socket类型选用PULL, 发送端的socket类型选用PUSH。对于ZEQ_PULL类型只能接收消息,对于ZEQ_PUSH类型只能发送消息。
单生产者多消费者通道,接收端connect,发送端bind,可以实现单个发送者向多个接收者发送消息。接收端的socket类型选用PUSH, 发送端的socket类型选用PULL。对于ZEQ_PULL类型只能接收消息,对于ZEQ_PUSH类型只能发送消息。
项目中多生产者单消费者通道的作用是跨线程的消息通信。因为在我的内网穿透服务中,为了异步处理连接和数据的转发,我需要创建大量的新线程,线程之间不可避免的需要消息通信。比如,监听外部连接的程序是在一个线程中运行的(tcp_run_pool),负责向客户端发送连接到来需要申请数据通道的命令的是在另一个进程中运行的。这两个线程之间就存在请求响应关系。即监听外部连接线程在监听到连接时会查看数据通道(数据通道是下文会说)中是否有建立好的连接体,如果没有就会向数据通道请求消息队列中发送请求消息,ControlChannel线程接收到请求后,就会像客户端发送连接请求。
客户端接收到创建数据通道的命令,会先向服务端建立连接,然后发送一个消息说这个连接是数据通道。然后再和本地服务建立连接,将这两个连接体建立双向转发的关系。
服务端接收到连接的消息后,如果识别到是数据通道,就会将这个连接体加入到数据通道消息队列中,等待被tcp_run_poll线程接收然后进行数据的双向转发。
那为什么数据请求通道消息队列只用多生产者单消费者通道, 数据通道消息队列是单生产者多消费者通道呢?
首先我们来看数据请求通道,数据请求通道的消费端需要向客户端发送消息,而一个Control线程只会持有一个客户端连接,所以只有一个control线程能向客户端发送消息,所以是单消费者。但是外部连接是有多个的,所以向数据请求通道发送消息的线程可能有多个,所以是多生产者。
然后再来看数据通道消息队列,数据通道消息队列的生产者是有ControlChannelHandle持有的,而一个ControlChannelHandle只会收到一个客户端的连接,所以是单生产者;同时数据通道的消费者是tcp_run_pool中可能有多个外界连接同时向数据通道消费连接体,所以是多消费者。
非阻塞socket如何保证消息的完整性
在发送的消息的最后加上特殊字符作为分隔符,比如’\n’,然后在接收端接收到消息后,判断消息的最后一个字符是否是分隔符,如果是就说明这个消息是完整的,如果不是就说明这个消息是不完整的,需要继续接收数据,直到接收到分隔符为止。
多线程下如何正确的管理线程
在定义好线程对象后,我们需要在线程对象的生命周期中规定好他的执行策略。这里有两个执行策略:join / 合并 和detach / 分离。来分别看看这两种策略。首先是join策略,他会在调用该函数的地方阻塞住,等待线程执行完毕。然后是detach策略,当thread对象调用了detach,那么说明线程对象放弃了对线程资源的所有权,不再管理此线程,而是允许此线程独立的运行,在线程退出的时候释放所有分配的资源。
如果放弃了该线程的所有权后,调用joinable就是false,不可以再调用join函数,否则会抛出异常。所以在使用线程的时候,要根据线程的生命周期来选择合适的策略。通常不在推荐使用detach策略,线程应该被管理,而不是被放任。
epollpool内的epoll线程中的epoll对象的初始化
启动epoll线程需要等待epoll对象被初始化完成后才可以退出。所以在epoll进程的启动函数里,要忙询问epoll对象是否被初始化完成。同时询问和初始化不在同一个线程内,所以需要上锁和使用条件变量。