1.为什么用epoll,而不是select、poll
epoll是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集合来传递结果而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合,另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。
另外,epoll提供了边缘触发(ET)和水平触发(LT)两种工作模式。边缘触发模式只在状态变化时通知一次,适用于高性能的场景,而水平触发可以更容易地从中断的I/O操作中恢复,提供了灵活性。
边缘触发和水平触发
LT(level triggered):LT是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。
ET(edge-triggered):ET是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知。请注意,如果一直不对这个fd作IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知(only once).
1.1epoll的不足之处
1.定时的精度不够,只到5ms级别,select可以到0.1ms;
2.当连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好;
3.epoll_ctrl每次只能够修改一个fd(kevent可以一次改多个,每次修改,epoll需要一个系统调用,不能 batch操作,可能会影响性能)。
总结
- 适用范围:
select
适合文件描述符数量较少的场景;poll
消除了select
的文件描述符数量限制,但在大规模场景下性能不佳;epoll
是专为处理大量并发连接设计的,性能最优,但仅限于Linux系统。 - 性能优势:
epoll
在高并发环境下有明显的性能优势,特别是当活跃的连接只占总连接的一小部分时。 - 功能差异:
select
和poll
在接口和功能上较为相似,而epoll
提供了更高级的功能,如边缘触发和一次性通知,这有助于进一步优化应用程序的性能。
2.如何处理线程同步和并发问题?
这里没有直接使用C++提供的互斥锁以及条件变量,而是封装的C语言的pthread,因为thread其实也是基于pthread实现的。并且C++11里面没有提供读写互斥量,RWMutex,Spinlock等,在高并发场景,这些对象是经常需要用到的,所以选择自己封装pthread,同时大量使用了范围锁来实现互斥,范围锁是指用类的构造函数来加锁,用析造函数来释放锁。这种方式可以简化锁的操作,也可以避免忘记解锁导致的死锁问题。
mutex封装了下面内容:
Semaphore: 计数信号量,基于sem_t实现
Mutex: 互斥锁,基于pthread_mutex_t实现
RWMutex: 读写锁,基于pthread_rwlock_t实现
Spinlock: 自旋锁,基于pthread_spinlock_t实现
CASLock: 原子锁,基于std::atomic_flag实现
在C语言的并发编程中,锁是用于管理多线程对共享资源访问的机制,以保证数据的一致性和线程的同步。下面是你提到的几种锁的简介:
Mutex(互斥锁)
- 基于:
pthread_mutex_t
- 特点:Mutex(互斥锁)是最基本的线程同步机制。当一个线程获得互斥锁后,其他任何试图再次获得该锁的线程都会被阻塞,直到拥有锁的线程释放该锁。这保证了同一时刻只有一个线程可以访问被保护的资源或临界区。
- 使用场景:适用于保护短时间内完成的操作或对资源的快速访问。
RWMutex(读写锁)
- 基于:
pthread_rwlock_t
- 特点:读写锁允许多个读操作同时进行,但写操作是互斥的。如果一个线程持有写锁,其他线程既不能读也不能写;如果有多个线程持有读锁,则任何试图获取写锁的线程都会被阻塞,直到所有读锁都被释放。
- 使用场景:适用于读多写少的场景,能够提高并发度。
Spinlock(自旋锁)
- 基于:
pthread_spinlock_t
- 特点:自旋锁在尝试获取锁时会在一个循环中不断检查锁的状态。这意味着如果锁已经被占用,获取锁的线程将会处于忙等(busy-waiting)状态,直到锁变为可用。自旋锁不会使线程进入睡眠状态。
- 使用场景:适用于锁持有时间极短的场景,因为它避免了线程的上下文切换开销。但在锁被长时间持有的情况下,自旋锁会导致CPU时间的浪费。
CASLock(原子锁)
- 基于:
std::atomic_flag
- 特点:CASLock(Compare And Swap Lock)利用原子操作实现锁机制。它通过比较并交换操作来检查锁状态并尝试获取锁,这是一种无锁(lock-free)的同步机制。由于是原子操作,它保证了即使多个线程同时尝试修改同一个变量,每次也只有一个线程能成功。
- 使用场景:适用于高并发且锁持有时间极短的场景。CASLock可以减少线程阻塞和唤醒的开销,提高系统性能。
这些锁各有特点和适用场景,选择合适的锁类型可以根据实际需要来优化程序的并发性能和效率。
3.协程
协程是一种用户态的轻量级线程,它的调度完全由用户控制,不需要内核参与切换,因此协程之间切换的成本比线程之间切换要低得多。协程可以用于实现非阻塞的I/O操作,从而提高程序在并发处理时的性能,尤其是在I/O密集型应用中。
Libco 是一个由腾讯游戏服务器网络部开发的协程库,它非常轻量级,而且易于使用。Libco支持多种协程调度策略,并且提供了一套丰富的API。它实现了协程之间的高效切换,允许开发者编写出看似同步执行但在底层实际上是异步的非阻塞代码。
基于ucontext_t的协程实现 依赖于POSIX标准中定义的ucontext API,这些API允许程序保存和恢复上下文(context)。在这种实现方式中,每个协程都有自己的ucontext_t结构,这个结构包含了协程在某一时刻的寄存器状态、堆栈信息等。通过getcontext()
和setcontext()
调用,程序能够保存当前协程的状态,并切换到另一个协程继续执行。此外,makecontext()
可以用来初始化一个新的协程上下文,而swapcontext()
则用于在两个协程之间切换。
使用ucontext_t实现的协程在执行过程中,可以在任何需要长时间等待的操作(如I/O操作)时让出CPU,转而执行其他协程。这种设计模式使得CPU能够在协程中高效转换,从而在单个线程内实现类似多线程的并发执行效果。不过需要注意的是,ucontext API在一些现代操作系统中已经被废弃,因为它们涉及到的上下文切换在某些架构上可能并不高效,且有更现代的替代方案(如goroutines
in Go语言,或者async/await
in Python和JavaScript)。
另外,我这里简化了状态机,仅仅有就绪、运行、完成三个状态,为了减少CPU开销,实现了协程复用,当协程执行完毕后,重置该协程,并不再开辟内存。
总的来说,协程提供了一种避免传统多线程并发编程复杂性的方法,允许开发者以一种更简洁的方式来处理并发任务。而Libco等协程库的出现,则是为了简化协程的使用和提高其性能。
4.hook
hook实现借助了全局符号介入机制,
-
sleep延时系列接口,包括sleep/usleep/nanosleep。对于这些接口的hook,只需要给IO协程调度器注册一个定时事件,在定时事件触发后再继续执行当前协程即可。当前协程在注册完定时事件后即可yield让出执行权。
-
socket IO系列接口,包括read/write/recv/send…等,connect及accept也可以归到这类接口中。这类接口的hook首先需要判断操作的fd是否是socket fd,以及用户是否显式地对该fd设置过非阻塞模式,如果不是socket fd或是用户显式设置过非阻塞模式,那么就不需要hook了,直接调用操作系统的IO接口即可。如果需要hook,那么首先在IO协程调度器上注册对应的读写事件,等事件发生后再继续执行当前协程。当前协程在注册完IO事件即可yield让出执行权。
-
socket/fcntl/ioctl/close等接口,这类接口主要处理的是边缘情况,比如分配fd上下文,处理超时及用户显式设置非阻塞问题。
5.定时器
定时器采用最小堆设计,所有定时器根据绝对的超时时间点进行排序,每次取出离当前时间最近的一个超时时间点,计算出超时需要等待的时间,然后等待超时。超时时间到后,获取当前的绝对时间点,然后把最小堆里超时时间点小于这个时间点的定时器都收集起来,执行它们的回调函数。
关于定时器和IO协程调度器的整合。IO协程调度器的idle协程会在调度器空闲时阻塞在epoll_wait上,等待IO事件发生。在之前的代码里,epoll_wait具有固定的超时时间,这个值是5秒钟。加入定时器功能后,epoll_wait的超时时间改用当前定时器的最小超时时间来代替。epoll_wait返回后,根据当前的绝对时间把已超时的所有定时器收集起来,执行它们的回调函数。
与时间轮和时间堆不同之处
上面的两种定时器设计都依赖一个固定周期触发的tick信号。设计定时器的另一种实现思路是直接将超时时间当作tick周期,具体操作是每次都取出所有定时器中超时时间最小的超时值作为一个tick,这样,一旦tick触发,超时时间最小的定时器必然到期。处理完已超时的定时器后,再从剩余的定时器中找出超时时间最小的一个,并将这个最小时间作为下一个tick,如此反复,就可以实现较为精确的定时。
5.1 其他定时器介绍
通过定时器可以实现给服务器注册定时事件,这是服务器上经常要处理的一类事件,比如3秒后关闭一个连接,或是定期检测一个客户端的连接状态。
定时事件依赖于Linux提供的定时机制,它是驱动定时事件的原动力,目前Linux提供了以下几种可供程序利用的定时机制:
-
alarm()或setitimer(),这俩的本质都是先设置一个超时时间,然后等SIGALARM信号触发,通过捕获信号来判断超时
-
套接字超时选项,对应SO_RECVTIMEO和SO_SNDTIMEO,通过errno来判断超时
-
多路复用超时参数,select/poll/epoll都支持设置超时参数,通过判断返回值为0来判断超时
-
timer_create系统接口,实质也是借助信号,参考man 2 timer_create
-
timerfd_create系列接口,通过判断文件描述符可读来判断超时,可配合IO多路复用,参考man 2 timerfd_create
服务器程序通常需要处理众多定时事件,如何有效地组织与管理这些定时事件对服务器的性能至关重要。为此,我们要将每个定时事件分别封装成定时器,并使用某种容器类数据结构,比如链表、排序链表和时间轮,将所有定时器串联起来,以实现对定时事件的统一管理。
每个定时器通常至少包含两个成员:一个超时时间(相对时间或绝对时间)和一个任务回调函数。除此外,定时器还可以包括回调函数参数及是否自动重启等信息。
有两种高效管理定时器的容器:时间轮和时间堆,我这里使用时间堆的方式管理定时器。
5.2 如何管理定时器事件
基于升序链表的定时器
6.输入网址到网页显示中间发生了什么
当你在浏览器中输入一个网址并按下回车键后,会发生一系列的操作,以确保将请求的网页显示在你的屏幕上。这个过程大致可以分为以下几个步骤:
-
解析网址 (URL Parsing): 浏览器首先解析输入的网址,这个网址指明了你希望访问的资源在互联网上的位置。
-
DNS 查询 (DNS Lookup): 浏览器需要将网址(如 www.example.com)转换成服务器的IP地址,这一过程称为DNS(域名系统)查询。浏览器首先检查自身缓存,如果没有找到,就会向配置的DNS服务器发送查询请求。
-
建立连接 (Establishing a Connection): 一旦浏览器得到服务器的IP地址,它会尝试通过互联网与服务器建立一个连接。这通常涉及到建立一个TCP(传输控制协议)连接,如果网站支持HTTPS,还将包括一个安全层(SSL/TLS)握手过程,以确保数据传输的安全。
-
发送HTTP请求 (Sending HTTP Request): 连接建立后,浏览器会向服务器发送一个HTTP请求。这个请求包含了请求的网页、请求方法(如GET或POST)和其他一些头部信息。
-
服务器处理请求并响应 (Server Processing and Response): 服务器接收到请求后,会处理这个请求,并返回一个HTTP响应。响应通常包含请求的网页内容,状态码(如200表示成功,404表示未找到等),以及一些响应头。
-
渲染网页 (Rendering the Page): 一旦浏览器接收到服务器的响应,它会开始解析和渲染网页。这包括解析HTML代码、加载CSS样式、执行JavaScript代码等。这个过程中,浏览器可能还会发送额外的请求,来获取如图片、视频等外部资源。
-
显示网页 (Displaying the Page): 在所有的资源都被加载并且网页被渲染后,最终的页面就会显示在用户的屏幕上。
这个过程虽然听起来很复杂,但实际上通常在几秒钟内就可以完成。这得益于现代互联网的基础设施和浏览器的高效性。