公众号:畅游码海 更多高质量原创文章都在里面~
主机字节序和网络字节序:
在32位机器上,累加器一次能装载4个字节,这四个字节在内存中排列顺序将影响它被累加器装载成的整数的值
大端字节序(网络字节序):一个整数的高位字节存储在内存的低地址处
小端字节序(现代PC大多数采用):整数的高位字节存储在内存的高地址处
即使是同一台机器上不同语言编写的程序通信,也要考虑字节序的问题
Linux下字节序转换函数:
1 | c++复制代码 #include<netinet/in.h> |
socket地址
1 | c++复制代码 #include<bits/sockets.h> |
协议族 | 地址族 | 描述 | 地址值含义和长度 |
---|---|---|---|
PF_UNIX | AF_UNIX | UNIX本地域协议族 | 文件的路径名,长度可达108字节 |
PF_INET | AF_INET | TCP/IPv4协议族 | 16bit端口号和32bit IPv4地址,6字节 |
PF_INET6 | AF_INET6 | TCP/IPv6协议族 | 16bit端口号,32bit流标识,128bit IPv6地址,32bit范围ID,共26字节 |
为了容纳多数协议族地址值,Linux重新定义了socket地址结构体
1 | c++复制代码#include<bits/socket.h> |
Linux为TCP/IP协议族有sockaddr_in和sockaddr_in6两个专用socket地址结构体,它们分别用于IPv4和IPv6
1 | c++复制代码 //对于IPv4的: |
使用的时候要强制转换成通用的socket地址类型socketaddr
点分十进制字符串表示的IPv4地址和网络字节序整数表示的IPv4地址转换
1 | c++复制代码 #incldue<arpa/inet.h> |
1 | c++复制代码//功能同上,可用于IPv6 |
创建socket
Linux上所有东西都是文件
1 | c++复制代码 #include<sys/types.h> |
socket系统调用成功时返回一个socket文件描述符,失败则返回-1并设置errno
命名socket
创建了socket,并且指定了地址族,但是并没有指定使用地址族中具体socket地址
将一个socket与socket地址绑定称为给socket命名
客户端通常不需要命名socket,而是采用匿名方式,即使用操作系统自动分配的socket地址
1 | c++复制代码 #include<sys/types.h> |
两种常见的errno是EACCES和EADDRINUSE
EACCCES:被绑定的地址是受保护的地址,仅超级用户能访问。
EADDRINUSE: 被绑定的地址正在使用中(例如将socket绑定到一个处于TIME_WAIT状态的socket地址)
监听socket
命名后,还不能马上接受客户连接,我们需要使用如下系统调用来创建一个监听队列以存放待处理的客户连接
1 |
|
内核版本2.2之前 :backlog参数是指多有处于半连接的状态(SYN_RCVD)和完全连接状态(ESTABLISHED)的socket的上限
内核版本2.2之后:它只表示处于完全连接状态的socket的上线,处于半连接状态的socket的上限,则是在tcp_max_syn_backlog内核参数定义。
backlog参数的典型值是5,listen成功时返回0,失败则返回-1并设置errno
接受连接
1 | c++复制代码 #include<sys/types.h> |
发起连接
1 | c++复制代码 #include<sys/types.h> |
connect成功时返回0,一旦成功建立连接,sockfd就唯一的标识了这个连接,客户端就可以通过读写sockfd来与服务器通信。失败返回-1并设置errno
ECONNREFUSED: 目标端口不存在,连接被拒绝
ETIMEDOUT: 连接超时
关闭连接
1 | c++复制代码 #include<unistd.h> |
多进程程序中,一次系统调用将默认使父进程中打开的socket的引用计数加1,因此我们必须在父进程和子进程中都对该socket执行close调用才能将连接关闭
如果无论如何都要立即终止连接,可以使用shutdown系统调用
1 | c++复制代码 #include<sys/socket.h> |
可选值 | 含义 |
---|---|
SHUT_RD | 关闭sockfd上读的这一半。应用程序不再针对socket文件描述符执行读操作,并且该socket接收缓冲区中的数据都被丢弃 |
SHUT_WR | 关闭sockfd上写的这一半。sockfd的发送缓冲区中的数据会真正关闭连接之前全部发送出去,应用程序不可再对该sockfd文件描述符执行写操作。这种情况下,连接处于半连接状态 |
SHUT_RDWR | 同时关闭sockfd上读和写 |
shutdown能够分别关闭sockfd上的读和写,或者都关闭。而close在关闭连接时只能将sockfd上的读和写同时关闭
shutdown成功时返回0,失败则返回-1并设置errno
数据读写
tcp 数据读写
1 | c++复制代码 #include<sys/types.h> |
flags
参数提供额外的控制
UDP数据读写
1 | c++复制代码 #include<sys/types.h> |
这两个也可用于面向连接的socket的数据读写,只需要把最后两个参数都设置为NULL以忽略发送端/接收端的socket地址(已经建立连接了,就知道socket地址了)
通用数据读写的函数
1 | c++复制代码#include<sys/socket.h> |
对于recvmsg来说,数据将被读取并存放在msg_iovlen块分散的内存中,这些内存的位置和长度则由msg_iov指向的数组指定,这称为分散读;对于sendmsg而言,msg_iovlen块分散内存中的数据将被一并发送,这称为集中写
带外标记
1 | c++复制代码 #include<sys/socket.h> |
地址信息函数
1 | c++复制代码 #include<iosstream> |
socket选项
1 | c++复制代码 #include<sys/socket.h> |
两个函数成功返回0 ,失败返回-1并设置errno
网络信息API
1 | c++复制代码 //根据主机名,获取主机的完整信息 |
1 | c复制代码 |
1 | c++复制代码 //通过主机名获取IP地址,也能通过服务名获得端口号----内部使用的是geihostbyname和getservbyname |
该函数将隐式的分配堆内存,所以我们需要配对下面的函数
1 | c++复制代码 //用来释放内存 |
1 | c++复制代码 //将返回的主机名存储在hsot参数指向的缓存中,将服务名存储在serv参数指向的缓存中,hostlen和servlen参数分别指定这两块缓存的长度 |
六、高级I/O函数
1 | c++复制代码 //pipe函数可用于创建一个管道,以实现进程间通信 |
1 | c++复制代码 //方便创建双向管道 |
1 | c++复制代码 //把标准输入重定向到文件或网络 |
1 | c++复制代码 //分散读和集中写 |
1 | c++复制代码 //在两个文件描述符之间传递数据(完全在内核中操作),从而避免了内核缓冲区和用户缓冲区之间的数据拷贝,效率很高,这被称为--------零拷贝 |
1 | c++复制代码 //用于申请一段内存空间 |
1 | c++复制代码 //用来在两个文件描述符之间移动数据----零拷贝 |
1 | c++复制代码 //在两个管道文件描述符之间复制数据,也就是零拷贝操作 |
1 | c++复制代码 //提供了对文件描述符的各种控制操作 |
七、Linux服务器程序规范
服务器程序规范:
Linux服务器程序一般以后台方式运行——守护进程
Linux服务器程序通常有一套日志系统,至少能输出日志到文件,有的高级服务器还能输出日志到专门的UDP服务器,大部分后台进程都在 /var/log目录下用哟哟自己的日志目录
Linux服务器程序一般以某个专门的非root身份运行
Linux服务器程序通常是可配置的,服务器通常能处理很多命令行选项,如果一次运行的选项太多,则可以用配置文件来管理,绝大多数服务器程序都是有配置文件的,并存放在/etc目录下
Linux服务器程序进程通常会在启动的时候生成一个PID文件并存入/var/run目录中记录该后台进程的PID
Linux服务器程序通常需要考虑系统资源和限制,以预测自身能承受多大负荷
日志
1 | c++复制代码 #include<syslog.h> |
用户信息
1 | //用来获取和设置当前进程的真实用户ID(UID)、有效用户ID(EUID )、真实组ID(GID)和有效组ID(EGID) |
一个进程拥有两个用户ID:UID和EUID,EUID存在的目的是方便资源访问:它使得运行程序的用户拥有该程序的有效用户的权限
进程间关系
进程组
1 | c++复制代码 #include<unistd.h> |
每个进程都有一个首领进程,其PGID和PID相同。进程将一直存在,直到其他所有进程都退出,或者加入到其他进程组
会话
1 | //创建一个会话 |
进程间关系
系统资源限制
1 | //Linux上运行的程序都会受到资源限制的影响 |
改变工作目录和根目录
1 | c++复制代码 #include<unistd.h> |
八、高性能服务器程序框架
I/O处理单元—四种I/O模型和两种高效事件处理模式
服务器模型
C/S模型
由于客户连接请求是随机到达的异步事件,因此服务器需要使用某种I/O模型来监听这一事件
1 | markdown复制代码 **当监听到连接请求后,服务器就调用accept函数接受它,并分配一个逻辑单元为新的连接服务。** |
服务器同时监听多个客户请求是通过select系统调用实现的
C/S模型非常适合资源相对集中的场合,并且它实现也很简单,但其缺点也很明显,服务器是中心,访问量过大时,可能所有客户都会得到很慢的响应。
P2P模型
优点:资源能够充分、自由地共享
缺点:当用户之间传输的请求过多时,网络负载将加重
主机之前很难互相发现,所以实际使用的P2P模型通常带有一个专门的发现服务器
服务器编程框架
模块 | 单个服务器程序 | 服务器机群 |
---|---|---|
I/O处理单元 | 处理客户连接,读写网络数据 | 作为接入服务器,实现负载均衡 |
逻辑单元 | 业务进程或线程 | 逻辑服务器 |
网络存储单元 | 本地数据库,文件或缓存 | 数据库服务器 |
请求队列 | 各单元之间的通信方式 | 各服务器之间的永久TCP连接 |
I/O处理单元模块:等待并接受新的客户连接,接收客户数据,将服务器响应数据返回给客户端
逻辑单元通常是一个进程或线程:它分析并处理客户数据,然后将结果传递给I/O处理单元或者直接发送给客户端
网络存储单元:可以说数据库,缓存和文件,甚至是一台独立的服务器
请求队列:是各个单元之间的通信方式和抽象I/O处理单元接收到客户请求时,需要以某种方式通知一个逻辑单元来处理请求,多个逻辑单元同时访问一个存储单元时,也需要某种机制来协调处理竞态条件。请求队列通常被实现为池的一部分。对服务器来说,请求队列是各台服务器之间预先建立的,静态的、永久的TCP连接
I/O模型
I/O模型 | 读写操作和阻塞阶段 |
---|---|
阻塞I/O | 程序阻塞于读写函数 |
I/O复用 | 程序阻塞于I/O复用系统调用,但可同时监听 多个I/O事件,对I/O本身的读写操作是非阻塞的 |
SIGIO信号 | 信号触发读写就绪事件,用户程序执行读写操作。程序没有阻塞阶段 |
异步I/O | 内核执行读写操作并触发读写完成事件,程序没有阻塞阶段 |
阻塞式IO
- 使用系统调用,并一直阻塞直到内核将数据准备好,之后再由内核缓冲区复制到用户态,在等待内核准备的这段时间什么也干不了
- 下图函数调用期间,一直被阻塞,直到数据准备好且从内核复制到用户程序才返回,这种IO模型为阻塞式IO
- 阻塞式IO式最流行的IO模型
优缺点
优点:开发简单,容易入门;在阻塞等待期间,用户线程挂起,在挂起期间不会占用CPU资源。
缺点:一个线程维护一个IO,不适合大并发,在并发量大的时候需要创建大量的线程来维护网络连接,内存、线程开销非常大。
非阻塞式IO
- 内核在没有准备好数据的时候会返回错误码,而调用程序不会休眠,而是不断轮询询问内核数据是否准备好
- 下图函数调用时,如果数据没有准备好,不像阻塞式IO那样一直被阻塞,而是返回一个错误码。数据准备好时,函数成功返回。
- 应用程序对这样一个非阻塞描述符循环调用成为轮询。
- 非阻塞式IO的轮询会耗费大量cpu,通常在专门提供某一功能的系统中才会使用。通过为套接字的描述符属性设置非阻塞式,可使用该功能
优缺点
同步非阻塞IO优点:每次发起IO调用,在内核等待数据的过程中可以立即返回,用户线程不会阻塞,实时性较好。
同步非阻塞IO缺点:多个线程不断轮询内核是否有数据,占用大量CPU时间,效率不高。一般Web服务器不会采用此模式。
多路复用IO
- 类似与非阻塞,只不过轮询不是由用户线程去执行,而是由内核去轮询,内核监听程序监听到数据准备好后,调用内核函数复制数据到用户态
- 下图中select这个系统调用,充当代理类的角色,不断轮询注册到它这里的所有需要IO的文件描述符,有结果时,把结果告诉被代理的recvfrom函数,它本尊再亲自出马去拿数据
- IO多路复用至少有两次系统调用,如果只有一个代理对象,性能上是不如前面的IO模型的,但是由于它可以同时监听很多套接字,所以性能比前两者高
- 多路复用包括:
- select:线性扫描所有监听的文件描述符,不管他们是不是活跃的。有最大数量限制(32位系统1024,64位系统2048)
- poll:同select,不过数据结构不同,需要分配一个pollfd结构数组,维护在内核中。它没有大小限制,不过需要很多复制操作
- epoll:用于代替poll和select,没有大小限制。使用一个文件描述符管理多个文件描述符,使用红黑树存储。同时用事件驱动代替了轮询。epoll_ctl中注册的文件描述符在事件触发的时候会通过回调机制激活该文件描述符。epoll_wait便会收到通知。最后,epoll还采用了mmap虚拟内存映射技术减少用户态和内核态数据传输的开销
- select:线性扫描所有监听的文件描述符,不管他们是不是活跃的。有最大数量限制(32位系统1024,64位系统2048)
优缺点
IO多路复用优点:系统不必创建维护大量线程,只使用一个线程,一个选择器即可同时处理成千上万个连接,大大减少了系统开销。
IO多路复用缺点:本质上,select/epoll系统调用是阻塞式的,属于同步IO,需要在读写事件就绪后,由系统调用进行阻塞的读写。
信号驱动式IO
- 使用信号,内核在数据准备就绪时通过信号来进行通知
- 首先开启信号驱动io套接字,并使用sigaction系统调用来安装信号处理程序,内核直接返回,不会阻塞用户态
- 数据准备好时,内核会发送SIGIO信号,收到信号后开始进行io操作
异步IO
- 异步IO依赖信号处理程序来进行通知
- 不过异步IO与前面IO模型不同的是:前面的都是数据准备阶段的阻塞与非阻塞,异步IO模型通知的是IO操作已经完成,而不是数据准备完成
- 异步IO才是真正的非阻塞,主进程只负责做自己的事情,等IO操作完成(数据成功从内核缓存区复制到应用程序缓冲区)时通过回调函数对数据进行处理
- unix中异步io函数以aio_或lio_打头
异步IO优点:真正实现了异步非阻塞,吞吐量在这几种模式中是最高的。
异步IO缺点:应用程序只需要进行事件的注册与接收,其余工作都交给了操作系统内核,所以需要内核提供支持。在Linux系统中,异步IO在其2.6才引入,目前也还不是灰常完善,其底层实现仍使用epoll,与IO多路复用相同,因此在性能上没有明显占优
五种IO模型对比
- 前面四种IO模型的主要区别在第一阶段,他们第二阶段是一样的:数据从内核缓冲区复制到调用者缓冲区期间都被阻塞住!
- 前面四种IO都是同步IO:IO操作导致请求进程阻塞,直到IO操作完成
- 异步IO:IO操作不导致请求进程阻塞
以上I/O模型详解部分来源于网络
两种高效的事件处理模式
两种事件处理模式Reactor和Proactor分别对应同步I/O模型、异步I/O模型
Reactor模式
它要求主线程(I/O处理单元)只负责监听文件描述上是否有事件发生,有的话就立即将该事件通知工作线程(逻辑单元)。除此之外,主线程不做任何其他实质性的工作。—–读写数据,接受新的连接,以及处理客户请求均在工作线程完成
- 主线程epoll内核事件表中注册socket上的读就绪事件
- 主线程调用epoll_wait等待socket上有数据可读
- 当socket上有数据可读时,epoll_wait通知主线程。主线程则将socket可读事件放入请求队列
- 睡眠在请求队列上的某个工作线程被唤醒,它从socket读取数据,并处理客户端请求,然后往epoll内核事件表中注册该socket上的写就绪事件
- 主线程调用epoll_wait等待socket可写
- 当socket可写时,epoll_wait通知主线程。主线程将socket可写事件放入请求队列
- 睡眠在请求队列上的某个工作线程被唤醒,它 往socket上写入服务器处理客户请求的结果
Proactor模式
Proactor模式将所有I/O操作都交给主线程和内核来处理,工作线程仅仅负责业务逻辑
- 主线程调用aio_read函数向内核注册socket上的读完成事件,并告诉内核用户读缓冲区的位置,以及读操作完成时如何通知应用程序
- 主线程继续处理其他逻辑
- 当socket上的数据被读入用户缓冲区后,内核将向应用程序发送一个信号,以通知应用程序数据已经可用
- 应用程序预先定义好的信号处理函数选择一个工作线程来处理客户请求。工作线程处理完客户请求之后,调用aio_write函数向内核注册socket上的写完成事件,并告诉内核用户写缓冲区的位置,以及写操作完成时如何通知应用程序
- 主线程继续处理其他逻辑
- 当用户缓冲区的数据被写入socket之后,内核将向应用程序发送一个信号,以通知应用程序数据以及发送完毕
- 应用程序预先定义好的信号处理函数选择一个工作线程来做善后处理,比如决定是否关闭socket
同步I/O模型模拟出Proactor
主线程执行数据读写操作,读完成之后,主线程向工作线程通知这一“完成事件”。那么从工作线程的角度来看,它们就直接获得了数据读写的结果,接下来要做的只是对读写的操作进行逻辑处理
- 主线程往epoll内核事件表中注册socket上的读就绪事件
- 主线程调用epoll_wait等待socket上有数据可读
- 当socket上有数据可读时,epoll_wait通知主线程。主线程从socket循环读取数据,直到没有更多数据可读,然后将数据封装成一个请求对象并插入请求队列
- 睡眠在请求队列上的某个工作线程被唤醒,它获得请求对象并处理客户请求,然后往epoll内核事件表中注册socket上的写就绪事件
- 主线程调用epoll_wait等待socket可写
- 当socket可写时,epoll_wait通知主线程。主线程往socket上写入服务器处理客户请求的结果
两种高效的并发模型
并发模型是指I/O处理单元和多个逻辑单元之间协调完成任务的方法。两种并发编程模式——-半同步/半异步模式、领导者/追随者模式
半同步/半异步模式
此同步和异步和前面I/O模型中的同步和异步完全不同。
1 | css复制代码 **在I/O模型中,“同步”和“异步”区分的是内核向应用程序通知的是何种I/O事件(是就绪事件还是完成事件),以及该由谁来完成I/O读写(应用程序还是内核)** |
半同步/半异步工作流程
半同步/半异步模式变体——半同步/半异步反应堆
1 | perl复制代码 |
缺点:
1 | markdown复制代码 **主线程和工作线程共享请求队列。主线程往请求队列中添加任务,或者工作线程从请求队列中去除任务,都需要对请求队列加锁保护,从而白白耗费CPU时间。** |
变体—-相对高效的
主线程只管理监听socket,连接socket由工作线程来管理。当有新的连接到来时,主线程就接受并将新返回的连接socket派发给某个工作线程,此后该新socket上的任何I/O操作都由被选中的工作线程来处理,直到客户关闭连接。主线程向工作线程派发socket的最简单的方式,是往它和工作线程之间的管道里写数据。工作线程检测到管道上有数据可读时,就分析是否是一个新的客户连接请求到来。如果是,则把该新socket上的读写事件注册到自己的epool内核事件表中
领导者/追随者模式
1 | less复制代码 **领导者/追随者模式是多个工作线程轮流获得事件源集合、轮流监听、分发并处理事件的一种模式。在任意时间点,程序仅有一个领导者线程,它负责监听I/O事件。而其他线程则都是追随者,它们休眠在线程池中等待成为新的领导者。当前的领导者如果检测到I/O事件,首先要从线程池中推选出新的领导者线程,然后处理I/O事件。此时,新的领导者等待新的I/O事件,而原来的领导者则处理I/O事件,二者实现并发** |
在逻辑单元内部的一种高效编程方法——–有限状态机
其他提高服务器性能的手段
内存池、进程池、线程池和连接池
避免不必要的拷贝,如使用共享内存、零拷贝
尽量避免上下文的切换(线程切换)和锁的使用,因为都会增加开销
多进程编程
fork系统调用
用来Linux下创建新进程的系统
1 | c++复制代码 #include<sys/types.h> |
fork函数复制当前进程,在内核进程表中创建一个新的进程表项。新的进程表项有很多属性和原进程相同,比如堆指针、栈指针和标志寄存器的值。但也有许多属性被赋予了新的值,比如该进程的PPID被设置成原进程的PID,信号位图被清楚(原进程设置的信号处理函数不再对新进程起作用)
- 子进程的代码与父进程完全相同,同时它还会复制父进程的数据(堆数据、栈数据和静态数据)。数据的复制采用的是所谓的写时复制,即只有在任一进程(父进程或子进程)对数据执行了写操作时,复制才会发生(显示缺页中断,然后操作系统给子进程分配内存并复制父进程的数据)。即便如此,如果我们在程序中分配了大量内存,那么使用fork时也应该十分谨慎,避免没必要的内存分配和数据复制。创建进程后,父进程中打开的文件描述符默认在子进程中也是打开的,且文件描述符的引用计数加1.父进程的用户根目录,当前工作目录等变量的引用计数均会加1。
exec系列系统调用
1 | c++复制代码 #include<unistd.h> |
exec函数不会关闭原程序打开的文件描述符,除非该文件描述符被设置了类似SOCK_CLOEXEC的属性
处理僵尸进程
对于多进程程序而言,父进程一般需要跟踪子进程的退出状态。因此,当子进程结束运行时,内核不会立即释放该进程的进程表表项,以满足父进程后续对该子进程退出信息的查询(如果父进程还在运行)。子进程结束运行之后,父进程读取其退出状态之前,我们称该子进程继续运行。此时子进程的PPID将被操作系统设置为1,即init进程。init进程接管了子进程,并等待它结束。父进程退出之后,子进程退出之前,该子进程处于僵尸态。
1 | c++复制代码 //僵尸态会占据内核资源,因此使用下列函数来等待子进程的结束,并获取子进程的返回信息,从而避免了僵尸进程的产生,或者使子进程呢个的僵尸态立即结束 |
常在SIGCHLD信号中调用waitpid,并在循环中彻底结束一个子进程
管道
管道是父进程和子进程通信的常用手段。
管道能在父、子进程间传递数据,利用的是fork调用之后两个管道文件描述符(fd[0]和fd[1])都保持打开。一堆这样的文件描述符只能保证父子进程间一个方向的数据传输,复制进程必须有一个关闭fd[0],另一个关闭fd[1]—-因此必须使用两个管道。
socket编程提供了一个双全工管道的系统调用:socketpair。———只能用于有关联的两个进程(如父子进程)
System IPC
这三种用来无关联的多个进程之间通信的方式: 信号、共享内存、消息队列
信号量
1 | lua复制代码 **当多个进程访问系统上的某个资源的时候,就需要考虑进程的同步问题,以确保任意时刻只有一个进程可以拥有对资源的独占式访问----我们称对共享资源的访问的代码为关键代码即临界区。** |
公众号里有我更多的原创文章,欢迎关注,支持原创!
本文转载自: 掘金