面试官:如何开发一个万级并发的抽奖系统?我:给你五个

每个生活在互联网时代的人,都一定经历过抢红包、秒杀、集卡、双色球等抽奖活动,这类活动其实有一个共同点:就是在某个时间点会瞬间涌入大量流量,给系统造成瞬间高于平时百倍、千倍甚至几十万倍的压力。

在企业面试中,「如何设计一个支持高并发的抽奖系统」也是常见问题之一,如果面试官问你这个题目,那么你必须要使出全身吃奶劲了,因为如果你确实有开发高并发系统的经验,那 offer 对你来说基本如探囊取物了。

提到「高并发」,就不得不提大名鼎鼎的 Go 语言了。Go 天生为高并发而生,Goroutine 和 channel 两样神器使得编写高并发的服务端软件变得相当容易。

今天就带大家用 Go 语言,实现抽奖、红包、大转盘、集福字、双色球 5 种抽奖系统!

面试官:如何开发一个万级并发的抽奖系统?我:给你五个_第1张图片

(下面的内容出自实验楼课程——「Go 语言实现高并发抽奖系统」,欢迎大家来实验楼边敲代码边学习~)

课程地址:

https://www.shiyanlou.com/courses/1597


首先,我们会进入一个全新的世界:并发编程,这将是一个全新的世界。也正是因为并发编程对于大家比较陌生,我这里也是准备了相应的先导课 + 实战的方式,通过实验的方式让大家去真实的感受并发编程的魅力。

对于先导课,我们将从最基础的线程与进程讲起,然后接触到 goroutine 和channel,再通过对于 Golang 的 sync 包的学习,慢慢深入对于 goroutine 和channel 的操作。

先导课的理论储备够了之后,我们就开始对于做一些实战的小项目,比如对年会抽奖,双色球,转盘,微信红包等抽奖活动的模拟,来进一步的巩固对于并发编程的学习成果。

实验介绍

在本节实验中,你将学习到 Golang 最有特色的部分:go 关键字与 Golang 的并发单元:goroutine。我们将从一些简单的实例开始,深入的带你去了解并发编程的世界。

知识点

  • goroutine 的调度细节

  • go 关键字

  • 进程与线程

  • goroutine 与线程的区别

前导内容:进程与线程

进程,描述的就是程序的执行过程,是运行着的程序的代表。换句话说,一个进程其实就是某个程序运行时的一个产物。如果说静静地躺在那里的代码就是程序的话,那么奔跑着的、正在发挥着既有功能的代码就可以被称为进程。

我们的电脑为什么可以同时运行那么多应用程序?这都是因为在它们的操作系统之上有多个代表着不同应用程序的进程在同时运行。

再来说说线程。首先,线程总是在进程之内的,它可以被视为进程中代码执行的流程。

一个进程至少会包含一个线程。如果一个进程只包含了一个线程,那么它里面的所有代码都只会被串行地执行。每个进程的第一个线程都会随着该进程的启动而被创建,它们可以被称为其所属进程的主线程。这种方法也是初学编程时最常见的编程方式。

相对应的,如果一个进程中包含了多个线程,那么其中的代码就可以被并发地执行。除了进程的第一个线程之外,其他的线程都是由进程中已存在的线程创建出来的。

这里关于并发和并行也是一个面试中常考的一个点。这里你可以用布雷谢斯在《并发的艺术》中所说的话作为答案:

如果某个系统支持两个或者多个动作(Action)同时存在,那么这个系统就是一个并发系统。如果某个系统支持两个或者多个动作同时执行,那么这个系统就是一个并行系统。并发系统与并行系统这两个定义之间的关键差异在于“存在”这个词。

在并发程序中可以同时拥有两个或者多个线程。这意味着,如果程序在单核处理器上运行,那么这两个线程将交替地换入或者换出内存。这些线程是同时“存在”的——每个线程都处于执行过程中的某个状态。如果程序能够并行执行,那么就一定是运行在多核处理器上。此时,程序中的每个线程都将分配到一个独立的处理器核上,因此可以同时运行。

我相信你已经能够得出结论,“并行”概念是“并发”概念的一个子集。也就是说,你可以编写一个拥有多个线程或者进程的并发程序,但如果没有多核处理器来执行这个程序,那么就不能以并行方式来运行代码。因此,凡是在求解单个问题时涉及多个执行流程的编程模式或者执行行为,都属于并发编程的范畴。

那么,其他的线程是如何由已存在的线程创建出来的呢?其实答案很简单,就靠我们写程序的人来控制。主线程之外的其他线程都只能由代码显式地创建和销毁。这需要我们在编写程序的时候进行手动控制,操作系统以及进程本身并不会帮我们下达这样的指令,它们只会忠实地执行我们的指令。

不过在 Golang 中的一大特色就是,Go 语言的运行时(runtime)系统会帮助我们自动地创建和销毁系统级的线程。这里的系统级线程指的就是我们刚刚说过的操作系统提供的线程。

而对应的用户级线程指的是架设在系统级线程之上的,由用户(或者说我们编写的程序)完全控制的代码执行流程。用户级线程的创建、销毁、调度、状态变更以及其中的代码和数据都完全需要我们的程序自己去实现和处理。

这带来了很多优势,比如,因为它们的创建和销毁并不用通过操作系统去做,所以速度会很快,又比如,由于不用等着操作系统去调度它们的运行,所以往往会很容易控制并且可以很灵活。

但是,劣势也是有的,最明显也最重要的一个劣势就是复杂。如果我们只使用了系统级线程,那么我们只要指明需要新线程执行的代码片段,并且下达创建或销毁线程的指令就好了,其他的一切具体实现都会由操作系统代劳。

但是,如果使用用户级线程,我们就不得不既是指令下达者,又是指令执行者。我们必须全权负责与用户级线程有关的所有具体实现。

操作系统不但不会帮忙,还会要求我们的具体实现必须与它正确地对接,否则用户级线程就无法被并发地,甚至正确地运行。毕竟我们编写的所有代码最终都需要通过操作系统才能在计算机上执行。这听起来就很麻烦,不是吗?这就引出了 Golang 的另一个优势:goroutine

Goroutine 和 Golang 独特的并发模型

前面我们学习了并发的概念,你可能对并发还是有很一些恐惧,产生一些并发程序大概很难写吧之类的情绪。其实不难,我们先新建一个 demo1.go 并复制下面一段代码:

package main
import "fmt"
func main(){
    for i:=0; i < 10; i++ {
        go func() {
            fmt.Println(i)
        }()
    }
}

在我们输入如下命令 go run demo1.go 运行 demo1.go 之前,我们在需要并发执行的函数前加一个 go 关键字,就代表了这个函数是需要并发执行的,这也是 Go 语言独特的魅力所在。那么,在你执行这行代码之前,请先思考一下,这个函数会打印出什么呢?

- name: 检查demo1.go是否存在
  script: |
    #!/bin/bash
    ls /home/project/demo1.go
  error: 未创建 demo1.go 文件

答案与为什么

这道题的回答是:不会有任何内容被打印出来。为什么会这样呢?

下面我来介绍一下 Golang 的并发模型:

Go 的并发实际上是由 Golang 的运行时系统的调度器来实现的,他负责调度 G (goroutine),P(processor)和 M(machine)。其中 M 指代的就是系统级线程,P 则是一个可以承载若干个 G ,并且能让 G 与 M 适时对接的一个中介。G 指的就是我们 go 关键字后面接的函数。

整个调度过程图示如下:

面试官:如何开发一个万级并发的抽奖系统?我:给你五个_第2张图片

面试官:如何开发一个万级并发的抽奖系统?我:给你五个_第3张图片

G 和 M 由于 P 的存在可以呈现出多对多的关系。当一个正在与某个 M 对接并运行着的 G,需要因某个事件(比如等待 I/O 或锁的解除)而暂停运行的时候,调度器总会及时地发现,并把这个 G 与那个 M 分离开,以释放计算资源供那些等待运行的 G 使用。

面试官:如何开发一个万级并发的抽奖系统?我:给你五个_第4张图片

面试官:如何开发一个万级并发的抽奖系统?我:给你五个_第5张图片

现在我们来解析之前的问题,为什么没有打印。

每一个独立的 Go 程序在运行时也总会有一个主 goroutine。这个主 goroutine 会在 Go 程序的运行准备工作完成后被自动地启用。而且通常这个主 goroutine 就是我们的主函数。

当程序执行到一条 go 语句的时候,Golang 的运行时系统,会先试图从某个存放空闲的 G 的队列中获取一个 G(也就是 goroutine),它只有在找不到空闲 G 的情况下才会去创建一个新的 G。

这也是为什么你总会听到 “启用” 一个 goroutine,而不说 “创建” 一个 goroutine 的原因。

然后拿到一个空闲的 G 之后, Golang 运行时系统就会把我们的函数包装到这个 G 中,然后再把这个 G 放到一个可运行的 G 队列中。而这个队列总是按照先入先出的顺序,很快由运行时系统内部的调度器安排运行。

所以,go 后面的函数的执行总是会比我们 go 执行的慢一步。在我们的十次 for 循环中,每次 go 实际上都是一个 goroutine 把这个函数放到相应的等待队列。

然后等待队列里面的函数还没来得及等到调度器来调度,for 循环执行完毕,主 goroutine 直接就退出了。这就是为什么我们运行这个程序之后什么都没有发生。

那么,既然是因为慢了一步没来得及调度就退出了,我们只需要等等他就可以了。

我们来看下面这段代码:

package main
import (
    "fmt"
    "time"
)
func main(){
    for i:=0; i < 10; i++ {
        go func() {
            fmt.Println(i)
        }()
    }
    time.Sleep(time.Second)
}

这里,我们引入了 time 包,通过等待一秒的方式来让程序得以输出。在运行之前请先猜一下,打印的顺序是 1 到 10,还是 10 个 10,还是其他的情况呢?

并且这里我们使用等一下的方式,不管怎么样,总归是不太聪明的样子。有没有方式能够让程序及时的退出并且 goroutine 也能得到调度呢?不要着急,我们在后面的实验中会给大家一个一个的介绍 Golang 中的开发工具包。

一个小挑战

这里大家执行完后应该都看到了结果,并没有输出 1 到 10 的数字。那么,我们是否可以使用如下的代码让他打印出 1 到 10 呢?

package main
import (
    "fmt"
    "time"
)

func main(){
    for i:=0; i < 10; i++ {
        go func(k int) {
            fmt.Println(k)
        }(i)
    }
    time.Sleep(time.Second)
}

如果打印出了乱序的 1 到 10,想一想我们之前所说的调度过程,为什么会是这样呢?

实验总结

在本节实验中,我们认识了什么叫做进程和线程,并发编程是什么,以及 goroutine 和 go 关键字。还看到了一种简单的情况下的 Goroutine 的调度器的调度过程。在下面的实验中,我们将开始学习 Golang 的并发工具包。

????????????点击阅读原文,学习完整课程内容~

你可能感兴趣的:(面试官:如何开发一个万级并发的抽奖系统?我:给你五个)