linux多线程服务器知识点
socket编程基础
网络编程步骤
TCP
-
服务端:socket -> bind -> listen -> accept -> recv/send -> close
- 创建一个socket,用函数socket(),设置SOCK_STREAM
- 设置服务器地址和侦听端口,初始化要绑定的网络地址结构
- 绑定服务器端IP地址、端口等信息到socket上,用函数bind()
- 设置允许的最大连接数,用函数listen()
- 接收客户端上来的连接,用函数accept()
- 收发数据,用函数send()和recv(),或者read()和write()
- 关闭网络连接close(),需要关闭服务端sock和accept产生的客户端sock文件描述符
-
客户端:socket -> connect -> send/recv -> close
- 创建一个socket,用函数socket()
- 设置要连接的对方的IP地址和端口等属性
- 连接服务器,用函数connect()
- 收发数据,用函数send()和recv(),或read()和write()
- 关闭网络连接close()
-
注意
- INADDR_ANY表示本机任意地址,一般服务器端都可以这样写
- accept中接收的是客户端的地址,返回对应当前客户端的一个clisock文件描述符,表示当前客户端的tcp连接
- send和recv中接收的是新建立的客户端的sock地址
UDP
- 服务端:socket -> bind -> recvfrom/sendto -> close
- 建立套接字文件描述符,使用函数socket(),设置SOCK_DGRAM
- 设置服务器地址和侦听端口,初始化要绑定的网络地址结构
- 绑定侦听端口,使用bind()函数,将套接字文件描述符和一个地址类型变量进行绑定
- 接收客户端的数据,使用recvfrom()函数接收客户端的网络数据
- 向客户端发送数据,使用sendto()函数向服务器主机发送数据
- 关闭套接字,使用close()函数释放资源
- 客户端:socket -> sendto/recvfrom -> close
- 建立套接字文件描述符,socket()
- 设置服务器地址和端口,struct sockaddr
- 向服务器发送数据,sendto()
- 接收服务器的数据,recvfrom()
- 关闭套接字,close()
- 注意
- sendto和recvfrom的第5个参数是sock地址
- 服务器端的recvfrom和sendto都是cli地址
- 客户端sendto是服务器端的地址,最后一个参数是指针,recvfrom是新建的from地址,最后一个参数是整型
- UDP不用listen,accept,因为UDP无连接
- UDP通过sendto函数完成套接字的地址分配工作
- 第一阶段:向UDP套接字注册IP和端口号
- 第二阶段:传输数据
- 第三阶段:删除UDP套接字中注册的目标地址信息
- 每次调用sendto函数都重复上述过程,每次都变更地址,因此可以重复利用同一UDP套接字向不同的目标传输数据
- sendto和recvfrom的第5个参数是sock地址
网络字节序和主机序
字节序分为大端字节序和小端字节序,大端字节序也称网络字节序,小端字节序也称为主机字节序。
- 大端字节序
- 一个整数的高位字节存储在低位地址,低位字节存储在高位地址
- 小端字节序
- 高位字节存储在高位地址,低位字节存储在低位地址
- 转换API
- htonl 主机序转网络序,长整型,用于转换IP地址
- htons 主机序转网络序,短整型,用于转换端口号
- ntohl 网络序转主机序
- ntohs 网络序转主机序
listen函数
监听socket,函数原型int listen(int sockfd, int bcaklog);
sockfd参数指定被监听的socket.backlog参数提示内核监听队列的最大长度。监听队列的长度如果超过backlog,服务器将不受理新的客户连接,客户端也将收到ECONNREFUSED错误信息。backlog参数的典型值是5。
网络编程socket之listen函数的解读:
参数backlog
这个参数涉及到一些网络的细节。在进程处理一个一个连接请求的时候,可能还存在其它的连接请求。因为TCP连接是一个过程,所以可能存在一种半连接的状态,有时由于同时尝试连接的用户过多,使得服务器进程无法快速地完成连接请求。如果这个情况出现了,服务器进程希望内核如何处理呢?内核会在自己的进程空间里维护一个队列以跟踪这些完成的连接但服务器进程还没有接手处理或正在进行的连接,这样的一个队列内核不可能让其任意大,所以必须有一个大小的上限。这个backlog告诉内核使用这个数值作为上限。
I/O处理单元
什么是I/O复用?
I/O复用是最常使用的I/O通知机制。指的是:应用程序通过I/O复用函数向内核注册一组事件,内核通过I/O复用函数把其中就绪的事件通知给应用程序。
Linux下实现I/O复用的系统调用主要有select,poll,epoll。
什么情况下需要使用I/O复用技术?
- 客户端程序要同时处理多个socket。比如非阻塞connect技术。
- 客户端程序要同时处理用户输入和网络连接。比如聊天室程序。
- TCP服务器要同时处理监听socket和连接socket。这是I/O复用使用最多的场合。
- 服务器要同时处理TCP请求和UDP请求。
- 服务器要同时监听多个端口,或者处理多种服务。
I/O模型对比
I/O模型 | 读写操作和阻塞阶段 |
---|---|
阻塞I/O | 程序阻塞于读写函数 |
I/O复用 | 程序阻塞于I/O复用系统调用,但可同时监听多个I/O事件。对I/O本身的读写操作是非阻塞的 |
SIGIO信号 | 信号触发读写就绪事件,用户程序执行读写操作。程序没有阻塞阶段 |
异步I/O | 内核执行读写操作并触发读写完成事件。程序没有阻塞阶段 |
注意:阻塞I/O、I/O复用和信号驱动I/O都是同步I/O模型。同步I/O要求用户代码自行执行I/O操作(将数据从内核缓冲区读入用户缓冲区,或将数据从用户缓冲区写入内核缓冲区)。而异步I/O机制则由内核来执行I/O操作(数据在内核缓冲区和用户缓冲区之间的移动由内核在“后台”完成的)。
select,poll,epoll的区别?
- 对于
select和poll
来说,所有文件描述符都是在用户态被加入其文件描述符集合的,每次调用都需要将整个集合拷贝到内核态;epoll
则将整个文件描述符集合维护在内核态,每次添加文件描述符的时候都需要执行一个系统调用。系统调用的开销是很大的,而且在有很多短期活跃连接的情况下,由于这些大量的系统调用开销,epoll
可能会慢于select和poll
。 select
使用线性表描述文件描述符集合,文件描述符有上限;poll
使用链表来描述;epoll
底层通过红黑树来描述,并且维护一个ready list,将事件表中已经就绪的事件添加到这里,在使用epoll_wait调用时,仅观察这个list中有没有数据即可。select和poll
的最大开销来自内核判断是否有文件描述符就绪这一过程:每次执行select或poll
调用时,它们会采用遍历的方式,遍历整个文件描述符集合去判断各个文件描述符是否有活动;epoll
则不需要去以这种方式检查,当有活动产生时,会自动触发epoll
回调函数通知epoll
文件描述符,然后内核将这些就绪的文件描述符放到之前提到的ready list中等待epoll_wait调用后被处理。select和poll
都只能工作在相对低效的LT模式下,而epoll
同时支持LT和ET模式。- 综上,当监测的fd数量较小,且各个fd都很活跃的情况下,建议使用
select和poll
;当监听的fd数量较多,且单位时间仅部分fd活跃的情况下,使用epoll
会明显提升性能。
ET和LT
对于采用LT工作模式
的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理该事件。这样,当应用程序下一次调用epoll_wait时,epoll_wait还会再次向应用程序通告此事件,直到该事件被处理。而对于采用ET工作模式
的文件描述符,当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理该事件,因为后续的epoll_wait调用将不再向应用程序通知该事件。
可见,ET模式在很大程度上降低了同一个epoll事件被重复触发的次数,因此效率要比LT模式高。
两种高效的事件处理模式
Reactor模式
:要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生(可读、可写)。若有,则立即通知工作线程(逻辑单元),将该事件放入请求队列,交给工作线程处理。Proactor模式
:将所有的I/O操作都交给主线程和内核来处理(进行读、写),工作线程仅负责处理逻辑,如主线程读完成后,选择一个工作线程来处理客户请求。
注意:关于reactor模式的工作流程、proactor模式、模拟proactor模式的内容,见游双《Linux高性能服务器编程》第128页。
Reactor模式的工作流程
使用同步I/O模型(以epoll_wait为例)实现的Reactor模式的工作流程:
- 主线程往epoll内核事件表中注册socket上的读就绪事件
- 主线程调用epoll_wait等待socket上有数据可读
- 当socket上有数据可读时,epoll_wait通知主线程。主线程则将socket可读事件放入请求队列
- 睡眠在请求队列上的某个工作线程被唤醒,它从socket读取数据,并处理客户请求,然后往epoll内核事件表中注册该socket上的写就绪事件。
- 主线程调用epoll_wait等待socket可写。
- 当socket可写时,epoll_wait通知主线程。主线程将socket可写事件放入请求队列。
- 睡眠在请求队列上的某个工作线程被唤醒,它往socket上写入服务器处理客户请求的结果。
线程的join和detach的区别?
区别在于两者是否阻塞主调线程:
- 当使用join()函数时,主调线程阻塞,等待被调线程终止,然后主调线程回收被调线程资源,并继续运行;
- 当使用detach()函数时,主调线程继续运行,被调线程驻留后台运行,主调线程无法再取得该被调线程的控制权。当主调线程结束时,由运行时库负责清理与被调线程相关的资源。
逻辑单元
什么是半同步/半反应堆线程池?
半同步/半反应堆工作流程(以Proactor模式为例)
- 主线程充当异步线程,负责监听所有socket上的事件
- 若有新请求到来,主线程接收之以得到新的连接socket,然后往epoll内核事件表中注册该socket上的读写事件
- 如果连接socket上有读写事件发生,主线程从socket上接收数据,并将数据封装成请求对象插入到请求队列中
- 所有工作线程睡眠在请求队列上,当有任务到来时,通过竞争(如互斥锁)获得任务的接管权
还有什么并发编程模式?
- 半同步/半异步模式
- 领导者/追随者模式
注意:关于两种高效的并发模式,见游双《Linux高性能服务器编程》第130页。
有限状态机
注意:关于有限状态机内容,见游双《Linux高性能服务器编程》第136页。
定时器模块
定时器的几种方式及区别?
- 升序链表、时间轮、时间堆(小根堆)。
- 添加、删除和执行定时任务的时间复杂度
添加 | 删除 | 执行定时任务 | |
---|---|---|---|
升序链表 | O(n) | O(1) | O(1) |
时间轮 | O(1) | O(1) | O(n) |
时间堆 | O(logn) | O(1) | O(1) |
改进?
当前项目仅使用一个时间轮。后续可考虑通过使用多个轮子,不同轮子拥有不同的粒度。
压测
什么是压测?
使用Webbench进行压测,其原理如下:
父进程fork若干个子进程,每个子进程在用户要求时间或默认的时间内对目标web循环发出实际访问请求,父子进程通过管道进行通信,子进程通过管道写端向父进程传递在若干次请求访问完毕后记录到的总信息,父进程通过管道读端读取子进程发来的相关信息,子进程在时间到后结束,父进程在所有子进程退出后统计并给用户显示最后的测试结果,然后退出。