勤快学

关于高性能 Web Server 的一些思考

开源中国  标签:Web  Server    发布于:2017年05月23日


Web 服务可以让你在 HTTP 协议的基础上通过 XML 或者 JSON 来交换信息。 醍醐灌顶!


你可以编写一段简短的代码,通过抓取这些信息然后通过标准的接口开放出来,就如同你调用一个本地函数并返回一个值。(rpc? rest?)


平台无关性。


目前主流的有如下几种Web服务:REST、SOAP。


作为客户端 向远端某台机器的的某个网络端口发送一个请求,作为服务端把服务绑定到某个指定端口,并且在此端口上监听。


Socket 编程

现在的网络编程几乎都是用Socket来编程

而Unix基本哲学之一就是“一切皆文件”,都可以用“打开open –> 读写write/read –> 关闭close”模式来操作。Socket也是一种文件描述符.


常用的Socket类型有两种:流式Socket(SOCK_STREAM)和数据报式Socket(SOCK_DGRAM)。


流式是一种面向连接的Socket,针对于面向连接的TCP服务应用;数据报式Socket是一种无连接的Socket,对应于无连接的UDP服务应用。


Socket 如何通信

网络中的进程之间通过Socket通信.利用三元组(ip地址,协议,端口)就可以标识网络中的唯一进程



TCP Socket


TCPConn 可以用在客户端和服务器端来读写数据


type TCPAddr struct {

    IP IP

    Port int

}


// TCPConn is an implementation of the Conn interface for TCP network connections.

type TCPConn struct {

    conn

}


type conn struct {

    fd *netFD

}


func (c *TCPConn) Write(b []byte) (n int, err os.Error)


func (c *TCPConn) Read(b []byte) (n int, err os.Error)


TCP Client


通过net包中的DialTCP()函数建立一个TCP连接,并且返回一个TCPConn.当连接建立的时候,服务器端也会创建一个同类型的对象。


Client:             Server:

   TCPConn <---> TCPConn


客户端通过TCPConn对象将请求信息发送到服务器端,读取服务器端响应的信息。


服务器端读取并解析来自客户端的请求,并返回应答信息


这个连接只有当任一端关闭了连接之后才失效。建立连接的函数定义如下:


func DialTCP(net string, laddr, raddr *TCPAddr) (c *TCPConn, err os.Error)


e.g.


func main() {

  if len(os.Args) != 2 {

    fmt.Fprintf(os.Stderr, "Usage: %s host:port ", os.Args[0])

    os.Exit(1)

  }

  service := os.Args[1]

  tcpAddr, _ := net.ResolveTCPAddr("tcp4", service)

  conn, _ := net.DialTCP("tcp", nil, tcpAddr)

  //话说可以直接走HTTP的包吗?不应该包装成TCP的包?还是系统帮你包装了

  _, _ = conn.Write([]byte("HEAD / HTTP/1.0\r\n\r\n"))

  result, _ := ioutil.ReadAll(conn)

  fmt.Println(string(result))

  os.Exit(0)

}


建立了TCP连接之后,就可以给对方发送HTTP的报文了?(应该是golang做了处理,把HTTP的报文包成了TCP的报文)


TCP Server


  1. 绑定服务到指定端口 func ListenTCP(net string, laddr *TCPAddr) (l *TCPListener, err os.Error)

  2. 监听端口 func (l *TCPListener) Accept() (c Conn, err os.Error)


service := ":7777"

tcpAddr, _ := net.ResolveTCPAddr("tcp4", service)

listener, _ := net.ListenTCP("tcp", tcpAddr)

for {

  conn, err := listener.Accept()

  if err != nil {

      continue

  }


  daytime := time.Now().String()

  conn.Write([]byte(daytime)) // don't care about return value

  conn.Close()                // we're finished with this client

}


注意在for循环中,当有错误发生的时候,直接continue而不是退出.在服务器端跑代码的时候,当有错误发生的时候,最好是记录错误,然后客户端报错退出,不要影响服务器运行的整个服务。


并发版本:


func main() {

  service := ":1200"

  tcpAddr, _ := net.ResolveTCPAddr("tcp4", service)

  listener, _ := net.ListenTCP("tcp", tcpAddr)

  for {

    conn, err := listener.Accept()

    if err != nil {

      continue

    }

    go handleClient(conn)

  }

}


func handleClient(conn net.Conn) {

  defer conn.Close()

  daytime := time.Now().String()

  conn.Write([]byte(daytime)) // don't care about return value

  // we're finished with this client

}


读取客户端请求,并保持长连接版本:


func handleClient(conn net.Conn) {

    conn.SetReadDeadline(time.Now().Add(2 * time.Minute)) // set 2 minutes timeout

    request := make([]byte, 128) // set maxium request length to 128B to prevent flood attack

    defer conn.Close()

    for {

        read_len, err := conn.Read(request)

        if err != nil {

            fmt.Println(err)

            break

        }


        if read_len == 0 {

            break // connection already closed by client

        } else if strings.TrimSpace(string(request[:read_len])) == "timestamp" {

            daytime := strconv.FormatInt(time.Now().Unix(), 10)

            conn.Write([]byte(daytime))

        } else {

            daytime := time.Now().String()

            conn.Write([]byte(daytime))

        }


        request = make([]byte, 128) // clear last read content

    }


}


request在创建时需要指定一个最大长度以防止flood attack,每次读取到请求处理完毕后,需要清理request,因为conn.Read()会将新读取到的内容append到原内容之后。


常用函数


设置建立连接的超时时间,客户端和服务器端都适用,当超过设置时间时,连接自动关闭。 func DialTimeout(net, addr string, timeout time.Duration) (Conn, error)


用来设置写入/读取一个连接的超时时间。当超过设置时间时,连接自动关闭。 func (c *TCPConn) SetReadDeadline(t time.Time) error func (c *TCPConn) SetWriteDeadline(t time.Time) error


设置keepAlive属性,是操作系统层在tcp上没有数据和ACK的时候,会间隔性的发送keepalive包,操作系统可以通过该包来判断一个tcp连接是否已经断开,在windows上默认2个小时没有收到数据和keepalive包的时候人为tcp连接已经断开,这个功能和我们通常在应用层加的心跳包的功能类似 func (c *TCPConn) SetKeepAlive(keepalive bool) os.Error


UDP Socket


UDP缺少了对客户端连接请求的Accept函数。其他和TCP的用法基本几乎一模一样。


Web 服务器工作模型

  1. 多进程方式:为每个请求启动一个进程来处理。单个进程问题不会影响其他进程,因此稳定性最好。性能最差

  2. 一个进程中用多个线程处理用户请求。线程开销明显小于进程,而且部分资源还可以共享,但是线程切换过快可能造成线程抖动,且线程过多会造成服务器不稳定。

  3. 异步方式:使用非阻塞方式处理请求。一个进程或线程处理多个请求,不需要额外开销,性能最好,资源占用最低。但是可能有一个请求占用过多资源,其他请求得不到响应.一个进程或线程处理多个请求,不需要额外开销,性能最好,资源占用最低。


一个 Web 请求的处理过程


简单来说就是:用户请求-->送达到用户空间-->系统调用-->内核空间-->内核到磁盘上读取网页资源->返回到用户空间->响应给用户。这里面有两个I/O过程,一个就是客户端请求的网络I/O,另一个就是Web服务器请求页面的磁盘I/O。


  1. 客户发起请求;

  2. 服务器网卡接受到请求后转交给内核处理;

  3. 内核根据请求对应的套接字,将请求交给工作在用户空间的Web服务器进程

  4. Web服务器进程根据用户请求,向内核进行系统调用,申请获取相应资源(如index.html)

  5. 内核发现web服务器进程请求的是一个存放在硬盘上的资源,因此通过驱动程序连接磁盘 内核调度磁盘,获取需要的资源

  6. 内核将资源存放在自己的缓冲区中,并通知Web服务器进程

  7. Web服务器进程通过系统调用取得资源,并将其复制到进程自己的缓冲区中

  8. Web服务器进程形成响应,通过系统调用再次发给内核以响应用户请求

  9. 内核将响应发送至网卡

  10. 网卡发送响应给用户


用户空间的web服务器进程是无法直接操作IO的,需要通过系统调用进行.



  1. 进程向内核进行系统调用申请IO

  2. 内核将资源从IO/DEVICE调度到内核的buffer中(wait阶段)

  3. 内核还需将数据从内核buffer中复制(copy阶段)到web服务器进程所在的用户空间,才算完成一次IO调度。


这几个阶段都是需要时间的。根据wait和copy阶段的处理等待的机制不同,可将I/O动作分为如下五种模式:


  1. 阻塞I/O:所有过程都阻塞

  2. 非阻塞I/O:如果没有数据buffer,则立即返回EWOULDBLOCK

  3. I/O复用(select和poll):在wait和copy阶段分别阻塞

  4. 信号(事件)驱动I/O(SIGIO):在wait阶段不阻塞,但copy阶段阻塞(信号驱动I/O,即通知)

  5. 异步I/O(aio)AIO


阻塞非阻塞:阻塞和非阻塞指的是执行一个操作是等操作结束再返回,还是马上返回。等待是阻塞的,自己轮询是非阻塞的 同步异步:是事件本身的一个属性.


I/O不管是I还是O,对外设(磁盘)的访问都要分成请求和执行两个阶段.请求就是看外设的状态信息(比如是否准备好了),执行才是真正的I/O操作.


非阻塞是主动查询外设的状态,select/poll也是主动查询,但是他们可以查询多个fd的状态,select有fd个数限制,epoll是基于回调函数的.用callback代替轮询.select是遍历fd.


epoll与IOCP比,epoll多了内核copy到应用层的阻塞


如何提高 Web 服务器的并发连接处理能力


  1. 基于线程,即一个进程生成多个线程,每个线程响应用户的每个请求。

  2. 基于事件的模型,一个进程处理多个请求,并且通过epoll机制来通知用户请求完成。

  3. 基于磁盘的AIO(异步I/O)

  4. 支持mmap内存映射,mmap传统的web服务器,进行页面输入时,都是将磁盘的页面先输入到内核缓存中,再由内核缓存中复制一份到web服务器上,mmap机制就是让内核缓存与磁盘进行映射,web服务器,直接复制页面内容即可。不需要先把磁盘的上的页面先输入到内核缓存去。


Apache 模型

之所以称之为应用服务器,是因为他们真的要跑具体的业务应用,如科学计算、图形图像、数据库读写等。


它们很可能是CPU密集型的服务,事件驱动并不合适。例如一个计算耗时2秒,那么这2秒就是完全阻塞的,什么event都没用。想想MySQL如果改成事件驱动会怎么样,一个大型的join或sort就会阻塞住所有客户端。


这个时候多进程或线程就体现出优势,每个进程各干各的事,互不阻塞和干扰。当然,现代CPU越来越快,单个计算阻塞的时间可能很小,但只要有阻塞,事件编程就毫无优势。所以进程、线程这类技术,并不会消失,而是与事件机制相辅相成,长期存在。


  1. prefork:多进程,每个请求用一个进程响应,这个过程会用到select机制来通知。最稳定

  2. worker:多线程,一个进程可以生成多个线程,每个线程响应一个请求,但通知机制还是select不过可以接受更多的请求。

  3. event:基于异步I/O模型,一个进程或线程,每个进程或线程响应多个用户请求,它是基于事件驱动(也就是epoll机制)实现的。一个进程响应多个用户请求,利用callback机制,让套接字复用,请求过来后进程并不处理请求,而是直接交由其他机制来处理,通过epoll机制来通知请求是否完成;在这个过程中,进程本身一直处于空闲状态,可以一直接收用户请求。可以实现一个进程程响应多个用户请求。支持持海量并发连接数,消耗更少的资源。


Nginx 模型

事件驱动服务器,最适合做的就是这种IO密集型工作,如反向代理,它在客户端与WEB服务器之间起一个数据中转作用,纯粹是IO操作,自身并不涉及到复杂计算。


Nginx会按需同时运行多个进程:一个主进程(master)和几个工作进程(worker),配置了缓存时还会有缓存加载器进程(cache loader)和缓存管理器进程(cache manager)等。


所有进程均是仅含有一个线程,并主要通过“共享内存”的机制实现进程间通信。主进程以root用户身份运行,而worker、cache loader和cache manager均应以非特权用户身份运行。


所以nginx是既不会有线程切换,也不会有进程切换.一个进程要跑在一个单独的核上会没有切换。


把一个完整的连接请求处理都划分成了事件,一个一个的事件。比如accept(), recv(),磁盘I/O,send()等,每部分都有相应的模块去处理,一个完整的请求可能是由几百个模块去处理。


真正核心的就是事件收集和分发模块,这就是管理所有模块的核心。只有核心模块的调度才能让对应的模块占用CPU资源,从而处理请求。


拿一个HTTP请求来说,首先在事件收集分发模块注册感兴趣的监听事件,注册好之后不阻塞直接返回,接下来就不需要再管了,等待有连接来了内核会通知你(epoll的轮询会告诉进程),cpu就可以处理其他事情去了。


一旦有请求来,那么对整个请求分配相应的上下文(其实已经预先分配好),这时候再注册新的感兴趣的事件(read函数),同样客户端数据来了内核会自动通知进程可以去读数据了,读了数据之后就是解析,解析完后去磁盘找资源(I/O),一旦I/O完成会通知进程,进程开始给客户端发回数据send(),这时候也不是阻塞的,调用后就等内核发回通知发送的结果就行。


整个下来把一个请求分成了很多个阶段,每个阶段都到很多模块去注册,然后处理,都是异步非阻塞。


事件驱动适合于IO密集型服务,多进程或线程适合于CPU密集型服务

,Nginx的进程也分为master进程跟worker子进程.(其实还有两个cache有关的进程, 这里略过).在启动nginx之后,master进程就会随即创建一定数量的worker子进程,并且之后worker子进程数量保持不变。并且这些worker子进程都是单线程的。


当一个请求到来时,worker进程中某一个空闲进程就会去处理这个请求.乍一看到这里nginx的工作模式跟apache没有什么区别.关键就在于nginx如何处理用户请求。


worker子进程开始处理请求.这个请求可能是访问某个网站的静态页面.而html页面都是保存在硬盘上的.站在操作系统角度来看,nginx是没有办法直接读取硬盘上的文件,必须由nginx告诉操作系统需要读取哪个文件,然后又操作系统去读取这个文件,读取完毕操作系统再交给nginx.也就是说,在操作系统读取文件的时候,nginx是空闲的。


如果是apache,那这个时候apache的worker进程/线程就阻塞在这里等待操作系统把文件读取好再交个自己,这种就称之为IO阻塞。


但是nginx不一样, nginx的worker进程在这个时候就会注册一个事件,相当于告诉操作系统:你文件读好了跟我说一下,我先去处理其他事情.然后这个worker就可以去处理新的用户请求了.这里nginx的worker进程并没有由于操作系统读取文件而阻塞等待,这种即称之为非IO阻塞。


当操作系统读取好文件之后,就会通知ngixn:我文件帮你读取好了,你过来拿走."操作系统读取好文件"这个事件被触发了,于是Nginx就跑回去把文件拿走,然后返回响应.这种由于某个事件出现触发Nginx执行操作的方式就称为事件驱动编程。


我们回顾上面过程,一个用户请求读取文件,nginx把读取文件这个事情通知操作系统之后就去处理下一个用户请求,直到操作系统读取好文件之后再返回响应.这种一个请求还没有处理完毕就去处理下一个请求的编程方式即异步编程。


master来管理worker进程,所以我们只需要与master进程通信就行了。master进程会接收来自外界发来的信号,再根据信号做不同的事情。所以我们要控制nginx,只需要通过kill向master进程发送信号就行了。


比如kill -HUP pid,则是告诉nginx,从容地重启nginx,我们一般用这个信号来重启nginx,或重新加载配置,因为是从容地重启,因此服务是不中断的。


master进程在接到信号后,会先重新加载配置文件,然后再启动新的worker进程,并向所有老的worker进程发送信号,告诉他们可以光荣退休了。新的worker在启动后,就开始接收新的请求,而老的worker在收到来自master的信号后,就不再接收新的请求,并且在当前进程中的所有未处理完的请求处理完成后,再退出。


worker进程之间是平等的,每个进程,处理请求的机会也是一样的。当我们提供80端口的http服务时,一个连接请求过来,每个进程都有可能处理这个连接所有worker进程的listenfd会在新连接到来时变得可读,为保证只有一个进程处理该连接,所有worker进程在注册listenfd读事件前抢accept_mutex,抢到互斥锁的那个进程注册listenfd读事件,在读事件里调用accept接受该连接。


当一个worker进程在accept这个连接之后,就开始读取请求,解析请求,处理请求,产生数据后,再返回给客户端,最后才断开连接,这样一个完整的请求就是这样的了。


我们可以看到,一个请求,完全由worker进程来处理,而且只在一个worker进程中处理。


首先,对于每个worker进程来说,独立的进程,不需要加锁,所以省掉了锁带来的开销,同时在编程以及问题查找时,也会方便很多。


其次,采用独立的进程,可以让互相之间不会影响,一个进程退出后,其它进程还在工作,服务不会中断,master进程则很快启动新的worker进程。


当然,worker进程的异常退出,肯定是程序有bug了,异常退出,会导致当前worker上的所有请求失败,不过不会影响到所有请求,所以降低了风险。



推荐阅读

20 个开源 Android App,帮助提高开发技巧

让开发更快更顺畅的 VS Code 插件推荐(二)

一名 40 岁“老”程序员的反思

用于构建优秀命令行的 4 个 Python 库

“放码过来”邀您亮“项”,一不小心就火了!

点击“阅读原文”查看更多精彩内容

上一篇:直播 | 喜马拉雅FM测试环境Docker化实践

下一篇:MSSQL-Scripter,一个新的生成T-SQL脚本的SQL Server命令行工具