Nginx 学习笔记3 高并发与go

最近和春晖、刘丁讨论定时器的问题,又仔细看了下 go timer 两个版本的实现,再结合 epoll 事件驱动,对比 Nginx, 实现方式如出一辙。只不过 go 的是无阻塞顺序编程,Nginx 异步回调。

高并发

老生常谈了,什么是“高并发编程”呢?核心只有两个,epollNonBlock. Nginx 和 go 实现方式太像了,go runtime 库所提供的接口都是无阻塞的,用 epoll 来实现事件驱动,效率非常高,后面的定时器就是典型案例。先举一个 openresty 的例子,这本书蛮不错,以后好好研究下:

location /sleep_1 {
    default_type 'text/plain';
    content_by_lua_block {
        ngx.sleep(0.01)
        ngx.say("ok")
    }
}

location /sleep_2 {
    default_type 'text/plain';
    content_by_lua_block {
        function sleep(n)
            os.execute("sleep " .. n)
        end
        sleep(0.01)
        ngx.say("ok")
    }
}

上面的配置,很好理解,两个 location 都是 sleep(0.01) 秒操作,但是区别在哪呢?先看压测

➜  nginx git:(master) ab -c 10 -n 20  http://127.0.0.1/sleep_1
...
Requests per second:    860.33 [#/sec] (mean)
...
➜  nginx git:(master) ab -c 10 -n 20  http://127.0.0.1/sleep_2
...
Requests per second:    56.87 [#/sec] (mean)
...

性能差距 10 倍,原因就在于 sleep1 使用 openresty 提供的非阻塞 sleep 操作,执行的时候会导致协程切换,出让 cpu, 但是 sleep2 调用了系统函数,这是阻塞的,cpu 空转,openresty lua 开发的坑也很多。

阻塞 Block 是高并发的敌人,go 同理,很多人认为 goroutine 很历害,但是一遇到 cgo, 或是需要系统调用就会出问题,阻塞操作占用了大量的 m, 系统线程猛增,这时 gc 又会出来捣乱。借用 qyuhen 老师的一句话,每写一行代码,都得知道背后发生了什么。为了搞清楚 go 背后的原理,最近 春晖 在翻译 go-internal,有兴趣的可以看看,需要有汇编和 go runtime 知识。

无阻塞操作

平时使用最多的 Read, Write 操作,go 都做了无阻塞封装。Netpoll Accept 连接时,先设置成 SetNonBlock 模式,再使用 epoll et 边缘触发方式注册到 netpoll 中。对于 Read 操作,如果当前有数据,那么读出后返回。如果没有,那么调用 waitRead 将当前 goroutine 挂起,让出 process, 等待网络消息或是超时回调唤醒

// Read implements io.Reader.
func (fd *FD) Read(p []byte) (int, error) {
        ......
    for {
        n, err := syscall.Read(fd.Sysfd, p)
        if err != nil {
            n = 0
            if err == syscall.EAGAIN && fd.pd.pollable() {
                if err = fd.pd.waitRead(fd.isFile); err == nil {
                    continue
                }
            }

                ......
        }
        err = fd.eofError(n, err)
        return n, err
    }
}

waitRead 函数最终会调用 netpollblock 函数,并 gopark 在这里,runtime 释放当前 goroutine 所使用的 process

func netpollblock(pd *pollDesc, mode int32, waitio bool) bool {
    gpp := &pd.rg
    if mode == 'w' {
        gpp = &pd.wg
    }
      ......
    if waitio || netpollcheckerr(pd, mode) == 0 {
        gopark(netpollblockcommit, unsafe.Pointer(gpp), "IO wait", traceEvGoBlockNet, 5)
    }
    // be careful to not lose concurrent READY notification
    old := atomic.Xchguintptr(gpp, 0)
    if old > pdWait {
        throw("runtime: corrupted polldesc")
    }
    return old == pdReady
}

那么 Read 函数是如何继续?goroutine 如何唤醒的呢?

  1. SetReadDeadline 超时到时,定时器唤醒
  2. Netpoll 收到了消息,触发epollin 消息唤醒

SetDeadline,每次对 conn 读写前设置,并且只对下一次读写生效。先来看一下 go 如何实现

//go:linkname poll_runtime_pollSetDeadline internal/poll.runtime_pollSetDeadline
func poll_runtime_pollSetDeadline(pd *pollDesc, d int64, mode int) {
      ......
    pd.seq++ // invalidate current timers 用来检测当前定时任务是否过期
    // Reset current timers. 删除老的任务
    if pd.rt.f != nil {
        deltimer(&pd.rt)
        pd.rt.f = nil
    }
    if pd.wt.f != nil {
        deltimer(&pd.wt)
        pd.wt.f = nil
    }
    // Setup new timers.
    if d != 0 && d <= nanotime() {
        d = -1
    }
    if mode == 'r' || mode == 'r'+'w' {
        pd.rd = d
    }
    if mode == 'w' || mode == 'r'+'w' {
        pd.wd = d
    }
    if pd.rd > 0 && pd.rd == pd.wd {
        pd.rt.f = netpollDeadline
        pd.rt.when = pd.rd
        // Copy current seq into the timer arg.
        // Timer func will check the seq against current descriptor seq,
        // if they differ the descriptor was reused or timers were reset.
        pd.rt.arg = pd
        pd.rt.seq = pd.seq
        addtimer(&pd.rt)
    } else {
        if pd.rd > 0 {
            pd.rt.f = netpollReadDeadline
            pd.rt.when = pd.rd
            pd.rt.arg = pd
            pd.rt.seq = pd.seq
            addtimer(&pd.rt)
        }
        if pd.wd > 0 {
            pd.wt.f = netpollWriteDeadline
            pd.wt.when = pd.wd
            pd.wt.arg = pd
            pd.wt.seq = pd.seq
            addtimer(&pd.wt)
        }
    }
    ......
}

上面是 SetDeadline 核心部份代码,比较容易理解

  1. 生成 seq 号,这个序列号用来判断当前定时器是否过期
  2. 查看是否设置了 pd.rt.f 定时器回调函数,删除上一次的任务
  3. 设置定时时间
  4. 将读|写任务加入定时器,任务到期后回调函数 netpollDeadline
func netpolldeadlineimpl(pd *pollDesc, seq uintptr, read, write bool) {
    lock(&pd.lock)
    // Seq arg is seq when the timer was set.
    // If it's stale, ignore the timer event.
    if seq != pd.seq {
        // The descriptor was reused or timers were reset.
        unlock(&pd.lock)
        return
    }
    var rg *g
    if read {
        if pd.rd <= 0 || pd.rt.f == nil {
            throw("runtime: inconsistent read deadline")
        }
        pd.rd = -1
        atomicstorep(unsafe.Pointer(&pd.rt.f), nil) // full memory barrier between store to rd and load of rg in netpollunblock
        rg = netpollunblock(pd, 'r', false)
    }
    var wg *g
    if write {
        if pd.wd <= 0 || pd.wt.f == nil && !read {
            throw("runtime: inconsistent write deadline")
        }
        pd.wd = -1
        atomicstorep(unsafe.Pointer(&pd.wt.f), nil) // full memory barrier between store to wd and load of wg in netpollunblock
        wg = netpollunblock(pd, 'w', false)
    }
    unlock(&pd.lock)
    if rg != nil {
        netpollgoready(rg, 0)
    }
    if wg != nil {
        netpollgoready(wg, 0)
    }
}

最终的定时任务到期,执行回调 netpollDeadline,执功能也很简单:

  1. 判断 seq 是否是最新的,对于长连接存在多次读写交互,正常情况网络 socket 不会超时,那么定时器触发后什么也不做
  2. 根据读|写事件,获取要唤醒的 goroutine
  3. netpollgoready 唤醒 goroutine, 所谓的唤醒只是标记成 可运行 状态,具体执行时间由 go runtime 决定

这是超时的情况,对于正常收到数据也很简单,findrunnable 调用 netpoll 获取收到事件的 goroutine, 标记成 runnable 可运行状态,具体执行时间由 go runtime 决定。代码参考 proc.go findrunnable 函数。

定时器

市面上流行的高效定时器有三种,go 使用的堆结构、linux kernel 使用的时间轮、nginx 红黑树。
go 在1.10前使用一个全局的四叉小顶堆结构,在面对大量连接时,定时器性能非常差,所以很多人实现了用户层的定时器库,很多公司还做过分享。但是 1.10 引入了 runtime 层的 64 个定时器,也就是 64 个四叉小顶堆定时器,性能提升不少。


golang timer

相比二叉,更遍平一些,增加删除都是 O(log4N) 级别,查询是O(1),但是为了维护堆结构也要额外操作 O(log4N)

时间轮有很多变种,内核使用了多级 time wheel, 没看过内核代码,举一个单轮的例子吧,图片来自csdn这篇文章

时间轮

一个轮有 N 个刻度,每个刻度是一个 t 嘀嗒时间,假如 N = 60, t = 1s, 那么就是生活中的秒针。时间轮初始化 N 个槽,每个槽是一个链表,在某一时刻加入一个时间为 T 的超时事件,cycle = T / t, n = T % t, 其中 cycle 是轮数,n 代表当前事件插入 current 时刻后的第 n 个槽。当时间流逝,指针指向下一个时刻,遍历槽内链表,cycle - 1, 如果为 0 那么回调当前超时任务函数,否则继续检查下一个任务。插入删除超时任务时间复杂度 O(1),查询是 O(n),但由于己经分成多个槽,所以效率肯定好于 O(n),多级时间轮计算更复杂。

红黑树有两篇文章不错,nginx红黑树详解, nginx学习9-ngx_rbtree_t,整体感觉效率和小顶堆差不多。Nginx 使用红黑树做定时器,举一个最熟悉的场景,接收到连接后,如果长时间没收到 http header, 那么 Nginx 会关闭这个连接。

epoll 驱动

Nginx 启动 N 个 worker, 并将 worker 和 cpu 进行绑定,每个 worker 有自己的 epoll 和 定时器,由于没有进程、线程切换开销,性能非常好。最近在看 Nginx 也引入了线程池,用于处理文件并发处理的情况,不过线上并没有用。

Epoll 开发多注意触发模式,默认是 LT 即水平触发,只要有数据可读|写,epoll_wait 返回时就一直携带 FD。而大部分服务都使用 ET 边缘触发模式,即从无数据到有数据,从不可写到可写,状态变化才会触发 epoll, 如果一次没有读完,内核里还有待读数据,那么 epoll 是不会触发的。所以 ET 使用的正确姿势,抱住 FD,一直读|写,直到遇到 EAGAIN、EWOULDBLOCK 错误。

Nginx 在这里也做了优化,如果有大段数据要读取或是发送,他会分多次调用的,防止当前 worker 其它任务饿死。上周公司同事将 1.9G 文件写到一个 git, 恰巧这个工程是服务配置库,每次上线都要拉取配置库的压缩包,直接将 Nginx 压跨了。

小结

高并发的要点就是无阻塞 NonBlock, 看 openresty 文档,官方 lua 实现的都是无阻塞的,有时间读读。

前几天某个大牛又提起所谓的 tcp 粘包、拆包问题,明明就是用户协义解析问题,非要发明新名词。thrift protobuf 反序列化就是个好例子。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,569评论 4 363
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,499评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,271评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,087评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,474评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,670评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,911评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,636评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,397评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,607评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,093评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,418评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,074评论 3 237
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,092评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,865评论 0 196
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,726评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,627评论 2 270

推荐阅读更多精彩内容