目录
1. 前言
2. 学习路线
2.1 C++
2.2 计算机网络基础
2.3 计算机系统基础
2.4 linux基础
2.5 服务端开发规范
3. 并发服务器开发入门
3.1 建立连接
3.2 IO多路复用
3.3 多进程并发
3.4 多线程并发
3.6 基于多线程并发服务器开发实例
3.6.1 常见问题
3.6.2 确定要实现的功能
3.6.3 选定事件处理模式和并发模式
3.6.4 确定线程交互方式
3.6.5 类的定义与功能
一、前言
入坑C++以后虽然也参加了一些比赛和做了一些项目,但是迫于时间等各方面的因素很多时候对C++的应用也只停留在STL和业务逻辑编程,很多C++的新特性都没有用到,讲道理也没有严格遵循编程规范。刚好这个寒假长得离谱,有了时间将以前买的几本砖头书重新开始看一遍,拉通思路后总算把以前一直想写的C++服务器写好了。这里顺便把我一个老萌新一些微不足道的经验分享给新入坑的新萌新们,希望能在学习C++网络编程的路上有所帮助吧。这里顺便附上本人的渣渣小服务器源码TinyWeb,欢迎大佬们指教。
二、学习路线
一说到C++网络编程如何入门,大佬们的回答出奇一致:看《Unix环境高级编程》和《Unix网络编程 卷一》。但是这两本书都是800多页,对萌新来说很不友好,很多人在这里就望而却步了。就我个人观点而言两本书其实也并不适合入门,严格来说是不适合系统的入门,因为这两本书的定位其实更接近于“字典”,《Unix环境高级编程》可以看做是linux并发编程的一本字典,而《Unix网络编程 卷一》则是linux网络编程的一本字典。正如我们系统学习一门语言一样,我们会使用字典,但绝不会说一开始靠着字典就融会贯通了,总要借助由浅到深的教材的辅助才能入门,入门后才能做到更好地使用字典。
就C++网络编程这个领域而言,基本上所需要点亮的基本技能点有:
C++基础计算机网络基础计算机系统基础linux基础服务端开发规范(1). C++入门
C++入门其实大家公认的是《C++ Primer Plus》,这本书确实写得很详细也很容易懂,唯一的美中不足是编程练习过于无聊,个人建议可以配套《算法笔记》进行学习,算法笔记是浙大一个研究生针对算法考试和计算机考研机试而编写的一本教材,里面主要有两部分:语言基础+数据结构,该书同时配备了大量的OJ题,利用里面的OJ题来学习C++我个人觉得是能达到事半功倍的效果,这就是应试教育的优越之处所在了。
(2)计算机网络
网络这方面我看的是《计算机网络:自顶向下方法》,这本书其实只要看前面几章就好了,计算机网络基础结构其实就是应用层-传输层-网络层-数据链路层-物理层,我们只要重点掌握前面三层的基础知识基本上对开发来说也就差不多了。针对Web服务器开发而言,看完应用层对应的章节能让我们知道如何分析http请求和做出对应的http应答,以及理解http和https协议的差异;看完传输层这一章能让我们知道怎么设置tcp连接和udp连接;看完网络层这一章能让我们处理ipv4连接和ipv6连接。
(3)计算机系统
理解计算机系统工作基本原理是十分重要的,如果没有这方面的相关知识的话其实是很难理解多线程与多进程工作原理的,在并发编程过程中也很容易踩坑。对于计算机系统基础的学习我个人是强推《深入理解计算机系统》,这本书对入门来说极其友好。对于服务器开发而言,我个人推荐重点看第3章、第8~12章这6个章节。第3章是从汇编语言的层次来理解程序运行过程,看完这一章对并发编程中“竞争”问题的理解将会很有帮助;第8章是进程、异常和信号的入门,看完这一章有助于我们理解内核是如何切换控制流;第9章是虚拟内存,看完这一章有助于我们理解多线程和多进程环境的内存分配;第10~12章这三章是服务器并发编程基础,看完这几章我们能对服务器开发的基本流程有个基本的了解。
(4)linux基础
由于服务器大多数时候是在unix环境下运行的,因此我们也需要掌握linux的一些基本指令。其实linux和windows一样也是一个操作系统,只不过操作繁琐了一些,需要我们掌握一些比点鼠标更高级的操作。linux操作的基本指令学习基本上大家用的都是《鸟哥的linux私房菜》,这本书很基础也很厚,但是其实我们只需要掌握一些基础的指令因此也没必要整本书全看完,我个人建议可以只看第4~6章,剩下的以后有时间再慢慢看。另外,linux的IDE也是与windows不同的,目前比较主流的的C++ IDE有Clion、Qtcreator和Vscode,我个人是推荐Qtcreator,主要是免费而且配置简单。
(5)服务端开发规范
基本上点亮上面的技能点之后对于如何开发一个简单的Web并发服务器应该已经有了自己的想法,这个时候当然可以开始搭建自己的服务器。不过这个时候写出来的服务器大概率效率不会太高而且也不符合规范。C++服务端开发其实已经是一个很成熟的领域,存在着大量公认的编程规范和主流技术。如果想针对性地提高服务器运行效率和开发效率,以及了解服务器开发中的某些规范,个人建议可以继续接着看《Linux高性能服务器编程》,这本书讲得很全面,也给出了基于进程池服务器开发和基于线程池服务器开发的两个实例,看完这本书能避免很多在服务器开发过程中可能遇到的坑,也能理解目前服务器开发的某些主流技术,例如:
主流服务器模型:C/S模型和P2P模型I/O模型:同步I/O与异步I/O事件处理模式:Reactor模式/Proactor模式并发模式:半同步/半异步模式、领导者/追随者模式针对以上主流技术,大牛们开发了标准库以提高开发效率,目前主流的C++网络编程库和I/O框架库有:Boost.Asio、libevent和muduo,这些库封装了网络编程中的繁琐操作同时兼顾了效率和安全性。如果想进一步提高自己的技术,建议可以看一下陈硕大佬的《Linux多线程服务端编程》,从源码的层次进一步理解C++网络编程。
三、并发服务器开发入门
(1)建立连接
网络连接的发起依赖于套接字接口,套接字接口是一组函数,它们与Unix I/O函数结合起来,用于创建网络应用,其中我们最常用到的函数有getaddrinfo、getnameinfo、socket、connect、bind、listen、accept。建立连接包括两部分,一部分是客户端发起连接,另一部分是服务端接收连接。
对于客户端,在发起连接之前首先要知道服务端的地址,地址包括两部分,分别是IP地址与端口,但是大多数情况下我们并不知道某个网站的IP地址和端口,只知道该网站的网站和提供的服务,这就是getaddrinfo函数的用途所在了,该函数通过读取系统配置文件和访问DNS服务器将网址和服务转换为套接字接口能够识别的地址。获得网站地址后,我们可以通socket函数来创建一个套接字描述符,再利用connect函数和已知的地址来建立和服务器的连接。
对于服务器而言,同样需要socket函数来创建一个套接字描述符,与客户端不同的是,服务端创建后的描述符还应通过bind函数绑定某个特定的套接字地址(包括IP地址和端口)。客户端的端口一般是随意分配的,但是对服务端而言,端口与服务端提供的服务是一一对应的,例如,端口80就规定用以提供www代理服务,因此通过bind函数绑定地址这一步对服务端而言必不可少。默认情况下,内核会认为socket函数创建的套接字对应于主动套接字,它存在于一个连接的客户端。服务器通过listen函数将主动套接字转换为被动套接字(监听描述符),从而告诉内核该套接字是被服务端使用而不是被客户端使用的。最后,服务器通过accept函数来等待客户端的连接请求,在建立连接后,该函数获得客户端的地址(我们可以通过getnameinfo函数将该地址转换为我们熟悉的IP地址和端口)并返回一个已连接描述符,服务端通过已连接描述符实现与客户端的通信。
总体连接建立流程如下图所示:
图1 连接建立过程初学者可能会对监听描述符和已连接描述符的区别感到困惑。一般而言,监听描述符是作为客户端连接请求的一个端点,它通常被创建一次,并存在于整个服务器的生命周期。已连接描述符是客户端和服务器已经建立起来的连接的一个端点,服务器每次接收连接请求都会创建一次,它只存在于服务器为一个客户端服务的过程之中,即对于每个客户端连接服务器都会建立一个已连接描述符为其服务。
建立连接注意事项:listen函数存在的陷阱。我们首先来看一下listen函数的函数原型:int listen(int sockfd,int backlog),其中sockfd是监听描述符,为了理解backlog参数,我们必须认识到内核为任何一个给定的监听套接口维护两个队列:
未完成连接队列:这个队列对应的连接是已由某个客户端发出并到达服务器,而服务器正在等待完成相应的TCP三路握手的过程,此时这些套接口处于SYN_RCVD状态。已完成连接队列:每个已完成TCP三次握手过程的客户端连接对应其中一项,此时这些套接口处于ESTABLISHED状态。两队列之和不超过我们设置的backlog参数,图解过程如图2所示:
图2 TCP为监听套接口维护的两个队列对于listen函数的使用要注意的问题是,等待accpct的总连接数不能超过我们设置的backlog参数,否则在accpect时内核将报错并设置errno。另一个问题是,已完成连接队列代表的是曾经完成TCP三次握手的队列,并不是实时的,因此也有可能出现accpct成功但读写失败的情况。
(2)I/O多路复用
I/O多路复用,I/O指的是I/O事件(包括I/O读写、I/O异常等事件),多路指多个独立连接(或多个Channel),复用指多个事件复用一个控制流(线程或进程)。串起来理解就是很多个独立I/O事件的处理依赖于一个控制流。
I/O复用使得程序能够同时监听多个独立的文件描述符从而提高程序性能。但要指出的一点是,I/O复用本身是阻塞的,但多个文件描述符同时就绪时,如果不采取额外的措施,程序就只能按顺序依次处理其中的一个文件描述符,这使得服务器程序看起来像是串行工作的。如果要实现并发,只能使用多进程或多线程等编程手段。
事件的到来是随机的、异步的,为了处理这些相互独立的事件我们需要借助内核的帮助,在事件发生之前,我们可以通过I/O复用函数将我们感兴趣的事件和文件描述符绑定起来并注册在内核中,由内核来监控事件何时发生,并通过I/O复用函数来通知用户事件的发生。从这个角度看,I/O复用函数可以看做是用户和内核之间通信的一个媒介。用户通过I/O复用函数注册自己感兴趣的事件,而内核通过I/O复用函数通知事件的发生。
目前广泛使用的I/O复用函数有select、poll和epoll三组函数,这3组函数都通过某种结构体变量来告诉内核监听哪些文件描述符上的哪些事件,并使用该结构体类型的参数来获取内核处理的结果。
(a)Select
select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是:1 单个进程可监视的fd数量被限制;2 需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大;3 对socket进行扫描时是线性扫描。
(b)Poll
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。它没有最大连接数的限制,原因是它是基于链表来存储的,但是同样有一个缺点:大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
(c)Epoll
epoll支持水平触发和边缘触发,最大的特点在于边缘触发,它只告诉进程哪些fd刚刚变为就需态,并且只会通知一次。在前面说到的复制问题上,epoll使用mmap减少复制开销。还有一个特点是,epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知。
这三组函数的区别如表1所示:
表1 3组I/O复用函数的区别(3)多进程并发
进程的的经典定义就是一个执行中的程序的实例。系统的每个程序都运行在一个进程的上下文中。上下文是由程序正常运行所需维护的状态组成的。这个状态包括存放在内存中的程序的代码和数据,它的栈、通用目的寄存器的内容、程序计数器、环境变量以及打开文件描述符的集合。内核通过调度器来实现对不同进程的调度,并通过上下文切换实现控制到新进程的转移,从而提供给应用程序两个抽象:
一个独立的逻辑控制流,它提供一个假象,好像我们程序独占地使用处理器。一个私有的地址空间,它提供一个假象,好像我们的程序独立地使用内存系统。进程的内存地址空间如图3所示,对于多进程并发,我们重点关注的是内存泄漏问题和进程通信问题。
图3 内存地址空间(a)多进程环境的内存泄漏问题
一般而言,多进程的实现依赖于父进程派生子进程,这个过程通过fork函数实现。新创建的子进程与父进程几乎但不完全相同,子进程得到与父进程完全相同(但相互独立)的用户虚拟地址空间,包括代码和数据段、堆、栈以及打开文件描述符的副本,它们最大的区别在于PID不同。由于子进程与父进程共享文件描述符,这意味着子进程可以打开父进程中打开的任何文件,这也导致了共享文件的在多进程环境下存在的内存泄漏问题。
内核用3个相关的数据结构来表示打开的文件,包括:
描述符表:每个进程都有它独立的描述符表,它的表项是由打开的文件描述符来索引的。每个打开的描述符表项指向文件表中的一个表项。文件表:打开文件的集合是由一张文件表来表示的,所有的进程共享这张表。每个文件表的表项组成包括当前文件位置、引用计数、以及一个指向v-node表对应表项的指针。关闭一个文件描述符会减少相应的文件表表项引用计数,内核不会删除这个文件表表项,直至它的引用计数减少为0。v-node表:所有进程共享一张v-node表,该表的表项包括的stat结构的大多数信息。由于文件表的引用计数的存在,因此父进程派生子进程时会导致父进程中打开的文件描述符对应的文件表表项引用计数加1,如果子进程和父进程其中有一个进程没有关闭文件描述符,则会导致无用文件表表项对内存空间的占用从而导致内存泄漏。因此,我们需要保证在父进程足够“干净”(没有打开大量的文件描述符和申请大量空间)的时候派生子进程来得到一个同样足够干净的子进程,从而避免内存泄漏。
(b)多进程环境的通信问题
因为多进程环境下各个进程内存地址空间相互独立,因此内存的通信只能依赖于内核。内核为多进程提供的通信机制有管道(以先进先出的方式接受数据)、共享内存(最高效的IPC机制,不涉及进程之间的数据传输,提供共享读机制)、消息队列(在两个进程之间传输二进制数据块)。
(4)多线程并发
线程是运行在进程上下文之中的逻辑流,线程由内核调度,每个线程都有它自己的线程上下文,包括一个唯一的整数线程ID、栈、栈指针、程序计数器、通用目的寄存器和条件码。所有运行在一个进程的线程共享该进程的虚拟地址空间。
由于多线程共享一个进程的虚拟地址空间,因此在多线程访问某些共享量时可能会出现“竞争”问题和“同步错误”,因此多线程并发要考虑的一个重要问题就是多线程同步。避免“同步错误”主要有以下几种机制。
使用内核辅助来避免同步错误:在多线程环境中我们可以像多进程环境一样,依赖于内核的管道机制或者IPC机制来实现不同线程之间的通信从而避免对共享变量的读写带来的同步错误,然而这种机制会造成逻辑流在用户态和内核态的反复切换,从而降低程序运行效率。使用互斥锁来避免同步错误:互斥锁由信号量实现,获得锁的线程独占对共享变量的访问,其他线程在访问该变量时会由于锁的存在而阻塞从而进入睡眠状态,这种机制保证了在任一时刻只有一个线程对共享变量的操作从而避免同步错误。然而锁的创建和解锁都依赖于内核,这也会导致一定的开销从而降低程序运行效率。使用自旋锁来避免同步错误:自旋锁与互斥锁有点类似,只是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁,"自旋"一词就是因此而得名。其作用是为了解决某项资源的互斥使用。因为自旋锁不会引起调用者睡眠,所以自旋锁的效率远 高于互斥锁。虽然它的效率比互斥锁高,但是自旋锁在自旋期间一直占用CPU,如果不能在很短的时间内获得锁这无疑会使CPU效率降低。使用原子变量来避免同步错误:C++ 11提供atomic原子类来实现对变量的原子操作,我们可以使用atomic_flag类型和atomic_int类型避免同步错误,使用原子变量还可以减少上锁带来的开销。(6)基于多线程并发服务器开发实例
(a)常见问题
服务器如何同时支持tcp连接和udp连接?可以创建两个socket描述符,一个描述符指定服务类型为tcp,另一个指定为udp,将这两个文件描述符绑定在同一个服务端地址上,通过I/O多路复用来监听这两个文件描述符事件的就绪。
服务器如何同时支持ipv4和ipv6?可以使用getaddrinfo函数,getaddrinfo 函数在 IPv6 和 IPv4 网络下都能实现独立于协议的名称解析,而且它返回的指向 addrinfo 结构的链表中会存放所有由输入参数 nodename 解析出的所有对应的 IP 信息,包括 IP 地址,协议族信息等。
服务器如何同时支持长连接和短连接?我们通过http请求报文的connection头部字段来判断当前连接是长连接还是短连接,如何是短连接,我们发送完http应答报文后立即关闭连接;对于长连接,我们利用时间轮或时间堆为长连接分配一个定时器并设定一个超时时间,如果在超时时间到来时长连接仍处于非活动连接状态,服务器则主动关闭长连接。
服务器如何同时支持获取静态内容和动态内容?我们将静态内容和动态内容放在服务器不同的文件夹下,根据http请求报文中的url地址来确定用户请求的是静态内容还是动态内容。请求静态内容则将对应内容通过文件映射或者集中写的机制将内容写到已连接描述符中。如果请求的是动态内容,则将参数从请求报文中提取出来并设置环境变量,并创建一个子进程,该子进程通过对环境变量的读取来获得程序参数,通过execve来加载CGI程序并把最后获得的结果通过标准输出重定向传递给对应的连接描述符。
应该建立几个线程?根据机器性能决定,机器有几个核就建立几个线程,为了避免线程抢占,可以使用pthread_setaffinity_np函数将线程绑定到某个CPU核心。
(b)确定自己要实现的功能:支持get请求;支持请求静态内容;支持ipv4;支持tcp;支持长连接/短连接;支持并发访问。
(c)选定事件处理模式和并发模式:事件处理模式采用Reactor模式,主线程只负责监听文件描述符是否有事件发生,读写数据、接收新的连接、以及处理客户请求均在工作线程中实现;使用半同步/半异步模式,每个线程(主线程和工作线程)都通过一个epoll维护自己的事件循环,它们各自独立地监听不同事件。
(d)选定线程交互方式:通过pipe管道和C++ 11的原子变量实现各线程之间的交互方式。
(e)类的定义与功能:
web_thread.h (1). 定义了webthread类; (2). 该类是主线程与子线程通信的媒介,本程序对于每个子线程建立对应的wedthread全局对象,主线程通过该对象来与子线程通信,子线程通过该对象接收来自主线程的消息,并运行该对象的work()函数来处理主线程消息和用户的http请求。 http_conn.h (1).定义了http_conn类和util_timer类; (2).http_conn类是用于处理http请求和作出http应答的一个类,本程序对于每个新连接的用户都分配一个http_conn对象用于处理http请求; (3).util_timer类是定时器类,本程序对于每个新连接的用户都分配一个定时器对象,并设置该定时器的超时时间,如果长连接用户在超时时间内都处于非活动状态则关闭用户连接,如果用户在超时时间到达之前重新活动则延长超时时间。web_function.h (1).该文件用于定义全局变量和常量,包括线程数、当前总用户数、读写缓冲区大小等,如果要支持更多的并发用户请求,应该修改该文件中定义的这些常量; (2).该文件还提供了一些通用的基本功能函数,例如显示当前时区时间的函数,显示用户ip地址的函数等。