谈一谈服务器开发

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/ZJU_fish1996/article/details/74784228


        服务器顾名思义就是提供服务的一方,一般的情况是:服务器开启,客户端发出请求,服务端响应请求,它涉及到了计算机的方方面面的知识,但是相比起日新月异的前端技术,相对而言比较稳定。


        从TCP开始


        谈到服务器,必然涉及到数据传输,这就不得不谈到网络传输中起到关键作用的TCP协议。

        首先我们需要明确一下TCP在协议栈中的地位。实际上的情况是,TCP更像是一个保证机制,底层的传输并不是由它负责的,它负责的包括:抽象了一个端口概念,定义从哪个端口传输到哪个端口,保证传输是可靠的等等。

        (1) 实际的0,1信号传输是在物理层完成的,它提供传输的方法;

        (2) 数据链路层负责控制这些信号的传输逻辑判断,比如控制碰撞时做些什么,数据没收到做些什么等;

        (3) 网络层控制网络路由相关,比如从哪个ip到哪个ip,在不同局域网如何传输等。


       在整个数据传输过程中,我们的数据被包裹在最里层,然后它依次"裹上"了TCP报文头尾部,IP报文头尾部等一直通往底层,然后在物理层穿着多层包裹进行传输,最后再一层层地脱下包裹,最后到用户手里只剩下原始的数据。

扫描二维码关注公众号,回复: 3467394 查看本文章


        在这些基础上,我们已经能够很好地完成数据传输了,因为我们已经有了传播的方式、逻辑以及链路。在此基础上,UDP协议仅仅做了一个简单的封装,也就是抽象出端口,这样就能在同一对ip的电脑上通过端口号来区分不同连接;然后做了一个简单的校验和,这个检验和不是CRC,而是对报文首尾部进行二进制反码求和,这个方法的检错能力其实比较弱的。UDP依赖于ICMP协议来控制报文传输,在校验失败的时候作为回应。

       TCP在UDP的基础上引入了可靠传输。


       乍一看TCP/UDP(运输层)和数据链路层内容非常相近。我们来观察一下它们的关键字:

       数据链路层:停止等待协议、回退N步协议、选择重传协议、CSMA/CD协议……

       传输层:拥塞控制、流量控制、滑动窗口协议、超时重传.....

       这种相近在于,它们看起来都是在做控制传输的可靠性的工作,这很容易让人困惑,但是要弄清它们的区别也是很简单的:

       (1) 数据链路层主要作用于局域网,保证在一个局域网内数据的正常传输。

       (2) 运输层主要作用于端到端的数据的正常传输。

       也就是说,当我们想要从一台电脑发送一点东西到另外一台电脑,这整个过程的可靠传输是由运输层来保证的。这两台电脑处在不同的局域网,在网络层的路由控制下,数据跨越了层层各式各样的局域网,最终达到了目的地,在每个局域网中前进的可靠传输就是由数据链路层来保证的。

        

      我们继续来看TCP。它的特性如下:

      (1) 面向连接。简单来说就是双方开始通信的时候,需要先通过三次握手建立连接,然后通过四次握手释放连接。


      (2) 点到点。通过二元组(ip + 端口号)来唯一标识两个端点。

            在具体的socket编程中,这意味着我们在开发服务器的时候,需要指明端口号,客户端连接的时候,也需要指明要连接到哪个ip的哪个端口号。但是客户端不需要指明自己的端口号,系统会为你分配一个没有使用过的端口号。


      (3) 可靠交付:无差错、不丢失、不重复、按序到达。

             所以使用TCP传输的时候,我们默认数据一定送达了,在这种情况下,如果数据没有收到,那么我个人能够想到的是如下两个原因:

             ① 数据已经到达了,但是在你的代码中,处理数据的速度远远比不上接收数据的数据,导致接收的消息队列溢出,数据被丢弃。

             ② 连接因为各种原因断开,导致本该发送的数据没有发送,本该接收的数据因断开没能接收。

            所以尽管它是可靠的,我们在应用层上可能会有其它的因素影响我们能否正确收到并处理这个数据,影响的因素会由你的代码框架设计、业务逻辑来决定,所以这也是不得不考虑的一个问题。


       (4) 全双工通信,也就是双方能在任何时候发送和接收数据,并有发送和接收缓存。

             在必要的情况下(或者说,我们几乎在所有的情况下都会这么做),我们为了能够很好地利用这一点,可以建立多线程进行数据传输,把发送事件和接收事件独立开来,一个线程处理发送,一个线程处理接收,我们最终的设计框架可以在这一基础上扩展,比如创建更多消息处理线程,网关分发数据到不同的服务器等。

       

        (5) 面向字节流,交付的数据是一连串无结构的字节流。

             这句话第一个含义是指消息是以字节的形式传输的,底层上就是010001。

             第二个含义是消息是以流的形式传输的,我们可以把点到点的信息传输通道看做是一个水管,我们在发送方每调用一次send,就向水管中灌入了一桶水,然后接收方每调用一次receive,就会从流中抽出一桶水。这就导致了这样的情况:

            ①  如果水是源源不断的,接收方提出来的水就很难再是发送方灌入的那桶水,中间可能混杂着发送方灌入的不同批次的水,有些是完整的,有些是不完整的。

            ② 水流能够保证有序性,也就是先抽出来的水,一定是先灌入的水、

            这可能会产生的问题就在于,如果我们的发送频率比较小,我们收到的数据和我们的发送的数据的边界很有可能是一致的,但是如果发送频率一高,我们就会频繁地面对拆包、粘包的现象。

           但是,无论拆包和粘包的现象会不会发生,我们在设计数据接收的代码时,都需要基于这一现象存在的基础上进行编写,我们应当通过在数据区加入开始标志等来标识一个数据包,而不是单纯的认为每次receive的包就是我们发送的那个完整数据包,然后从头开始解析。


       TCP的内容非常复杂,包括报文段结构、滑动窗口协议、超时重传、流量控制、拥塞控制,但是我们在讨论服务器设计中,这些内容可以暂时不考虑,我们只需要知道TCP给我们提供了些什么就足够了。


       thread的使用与消息队列

  

        对于大多数编程语言而言,它们都有自己对应的一套多线程实现机制,我们在网络传输主要用它来控制发送、接收、处理的分离。对于拥有大量的框架的语言而言,多线程和消息队列是系统库的一个内置功能,所以直接使用即可。

        我们在这里主要谈一谈C++。直到C++11,C++才推出了thread标准库用于实现多线程。它在不同平台下有着不同的实现方式,thread相当于供了一个统一的接口。在常用于服务器搭建的linux系统下,thread的本质就是pthread的封装,在编译包含thread库的时候我们都需要加上它的连接:-lphthread


        thread的用法比较简单,直接实例化一个thread,并且传入一个回调函数和必要的参数,但是需要注意这个参数不可以是指针或者引用,所以我们可以封装成一个message类,把指针放在这个类里,一起作为参数传进去。

        子线程在线程创建后立即开始执行。使用detach可以使主线程不等待子线程,使用join使主线程等待子线程完成才继续进行。


        关于消息队列。C++自身没有实现消息队列,如果需要使用的话,需要自己实现一个。可以考虑堵塞队列,在没有消息的时候堵塞等待。

        使用std::mutex定义一个互斥量。

        使用std::lock_guard作为锁来保护把消息放入消息队列这一行为的原子性,它在析构的时候会自动释放锁,并且唤醒所有锁。

        而在取数据的时候,使用同一互斥量,用std::unique_lock和std::condition_variable条件变量共同控制在队列为空的时候上锁,只有在放入新的消息时唤醒,才会继续执行从队列中取出数据的操作。队列本身用链表维护,不存在绝对的上限。


       关于合理的资源利用


       通过多线程,我们相当于有了多个员工,他们可以同时为我们做不同的事情,有人负责发送,有人负责接收,它们互不影响,对于程序设计者而言,我们更像是规划一条条流水线该如何运作,然后看着他们有条不紊的执行着自己的工作。

       但是,需要注意的是,线程并不是越多越好的,我们线程性能上限取决于我们的系统上限。在资源利用率不足的时候,多线程可以提高利用率,但是当达到一定限度后,多线程的切换会带来性能损耗。我们使用多线程往往是出于以下情况考虑:

      (1) 系统本身是多核设计,多几个线程可以映射到不同核心,提高利用率。

      (2) 避免后台执行大量计算的时候前端卡死。

      (3) 提高工作效率,比如在洗衣机运作的时候可以先去扫地而不是在原地等待。对于网络传输而言,如果只有一个线程,那么我们就变成了单流水线形式:我们一次只能做一件事情,要么接收,要么发送,如果我们一直在等待对方发送的数据,那么我们自身也无法发送数据,这就造成了浪费。

         所以这本质上是一种劳动力的浪费,使用多线程并不是提高了劳动力本身的工作能力,而是让他的效率提高了,减少了无谓的等待,多线程的难点在于合理的调度以及资源共享和同步等。

     ........

     

     I/O复用技术

      多线程是对CPU的利用,但是我们还可能会面临I/O操作,在服务器开发中,我们都需要面对大量的I/O,因为除了我们认知的文件读写,数据库读写之外,网络I/O也是一种I/O。我们往往需要处理大量的请求。

      在这一方面,我们可以考虑用线程来实现,一种比较容易想到的思路就是一个用户对应一个线程,这个仅仅在用户较少的时候可行,因为系统无法吃得消大量线程开销。针对海量用户并发访问,我们设计了线程池,它针对的是用户虽然很多但并不是所有用户都在随时随地请求,同一时刻活跃的用户是在一个范围内的。所以可以在一开始申请很多线程,避免频繁创建和销毁线程的开销。类似于http,采用的就是短连接无状态设计。但是线程池并不能应对所有情况。

      而对于每一个线程,每个I/O本质上都是堵塞的,也就是说,我们在一个时刻只能对一个文件句柄进行操作,但这对于多用户而言是不足的,我们在这一情况下可以考虑I/O复用技术。而对于I/O复用而言,一种思路是同步I/O,一种思路是异步I/O。

      设备调度是额外的部门,相当于一个新的劳动力,对于同步I/O而言,在执行I/O操作的时候,CPU空闲等待。而对于异步I/O而言,CPU发出请求后就立即返回该干啥干啥了,I/O接到指令开始工作,完成的时候可以发送一个回调信息。

      同步和异步并不对应着堵塞和非堵塞,它们讨论的是不同类型的问题。同步和异步讨论的是发出I/O请求后,是立即返回,还是等到I/O完成后才返回;而堵塞和非堵塞关注的是,发出I/O请求后,当前线程是否会堵塞等待。


       关于同步I/O,几个经典的例子是Linux的select, poll 以及epoll。

       select的本质就是申请了管理文件句柄的一个数组,然后每次轮询某个句柄查看是否被占用,如果I/O没有完成会持续通知进程,返回占用文件描述符的时候,需要从内核空间向用户空间拷贝,所以描述符存在上限,性能也比较低。poll的区别在于使用了链表而不是数组。

       而epoll本身是由红黑树和双链表维护的,一个事件对应一个epitem结构,每次检查是否有事件发生执行检查双链表中是否有epitem元素。插入事件和查找事件的算法复杂度都大大降低,空间复杂度因为树和链表的使用有提升。


      关于异步I/O,linux内核实现了一个AIO,它本身的思路是很有好的,但是在服务器开发的领域,它的普及程度似乎没有epoll那么大,这和它本身出现的比较晚,技术尚不成熟也有一定的关系。


      在服务器开发上,还有其它的一些语言可供参考:

      Java:一些现成的框架:多线程,线程池,目前好像已经有AsynchronousSocketChannel支持异步I/O了。

      Node.js:异步I/O,把js这门语言的异步特性发挥到了极致,但是不适用于CPU密集型的应用。

      Go:协程。也就是一种轻量级的线程,相当于在runtime维护了一个调度器。

      最近发现游戏常用脚本Lua也是原生支持协程。


     不同的开发框架的选择,包括传统的和比较新兴的语言,在市面上都有着自己的应用,不同的实现思路有着自己的适用领域,所以在选择的时候应当考虑到实际的需求。


     数据传输的本质


       网络数据传输基于TCP/UDP,而TCP/UDP是基于字节流传输的,它底层传输的是010001等,而以字节为最小传输单位(8个1或0),所以在接收过程中,大部分socket接口函数都是返回一个字节,或者返回特定最大长度字节的数据,存放在byte/char(或byte/char数组)中,而没有提供接收发送方传输的报文这一功能,因为报文不是数据传输的最小单位,在复杂的网络环境中,它甚至可能经过了不同的最大字节长度限制的网络,然后经过了很多拆包和合并的过程,最后到我们手里的已经很难再是完整的报文了。

        但是数据的顺序是没有改变的,因为我们知道TCP本身会对报文段进行编号,这个工作流程大致是这样的,接收方维护一个确认号,比如502,也就是说接下来它期待收到的报文编号是502,如果此时它收到了503,504这些,它可能会直接丢弃,或者缓存下来;收到502,会发一个确认信息,然后确认号变为503,重复收到501也会发确认信息。对于发送方而言,如果它超时没有收到确认,就会重新传一遍。

       对于接收方来说,它一旦确认了502,那么包括502之前的所有编号的数据包都已经确认接收了,不然序号是无法前进的。

       确认消息收到和保证有序性并不是那么简单的事情,TCP设计中窗口的大小,序列号的范围,重传时间的选择都是有讲究的,在此不做过多展开,重要的在于理解数据传输的有序性和底层的实现机制。


       底层传输的是字节流,但是我们不一定要局限于字节,可以用多种方式来编码,我们可以参考http的消息体设计:可读懂的文本,也可以用二进制进行编码,或者是xml/json这种经典的数据存储格式。


     RPC让设计更优雅


       在实际的服务器开发中,我们不一定会从TCP这样相对而言比较底层的地方开始实现,我们可以考虑在此基础上(TCP)的应用层RPC框架,和经典的http协议一样,我们在这个基础上,可以省去一些底层的设计,比如消息的编码,让整个编码过程更加优雅。

       RPC框架有很多,可以在网上找一些开源框架使用,也可以自己实现一个。但本质上都是对前面所提到的东西的一个封装。


猜你喜欢

转载自blog.csdn.net/ZJU_fish1996/article/details/74784228