2021-09-02

1.go中goroutine是如何调度的(go MPG模型)

M代表一个内核线程,也可以称为一个工作线程。goroutine就是跑在M之上的。(两个M如果运行在一个CPU上就是并发,如果运行在不同CPU就是并行。)

P 代表着处理器,或是程序执行上下文,将等待执行的G与M对接。Go的运行时系统会适时地让P与不同的M建立或断开关联,以使P中的那些可运行的G能够及时获得运行时机;

G代表协程(是一个轻量级的执行线程),可以有多个;

(go采用了基于消息并发模型的方式。它将基于CSP模型的并发编程内置到了语言中,通过一个go关键字就可以轻易地启动一个Goroutine,而且在Goroutine之间是共享内存的。)

2021-09-02_第1张图片

MPG的调度:

Go Goroutine采用的是半抢占式的协作调度,只有在G0发生阻塞时或者占用M超过10ms才会导致调度,否则会依次执行P绑定的其他G

G0阻塞时,调度器会将P与当前的M0和G0解绑,同时去空闲M列表中找新的M,如果没有则创建M1,绑定P,顺序执行P下的G。

当G0阻塞结束后,调度器会到空闲P列表中为M0找空闲可绑定的P,如果恰巧有P,则继续执行G0。如果没有可用的P,M0被放入空闲列表,等待调度给需要的G;G0被放入可运行的G列表,列表中的G会经由调度再次放入某个P的可运行G队列。(M1和M0可以是并行的)


当发生上下文切换时,先要把前一个任务的上下文保存起来,上下文包括:cpu寄存器和程序计数器(PC指针)。

协程的上下文信息会存到P中,进程/线程的上下文信息会存到内核当中。


2023.3.31补充:

每一个P包括: runnext列表(只存放一个g) 和本地runq列表(容量一般为256);每当一个新的g进入到P,runnext列表里的g会被移动至本地runq中,当本地runq已经满了,会将本地runq列表里一半g(1-128)移至全局runq列表。M上g的运行顺序为:runnext —> 本地runq—>全局runq,需要注意的是,为了避免所有p繁忙,全局runq列表等待过久的情况,会有将全局runq穿插在本地runq执行的调度策略。


2.context包的用途

Context 为同一任务的多个 goroutine 之间提供了退出信号通知和元数据传递的功能。

Context 是用来管理goroutine的,主要有两个作用:

  • 提供goroutine退出信号
  • 保存上下文数据。

gin框架中每个请求的处理都会开启一个协程,协程易于创建,也易于泄露,context(也叫做上下文)的出现就是为了管理协程。

所以 Context 模式声明一些接口,显式传递给子函数,子函数的 goroutine 主动检查 Context 的状态并作出正确的响应。

Context 包允许传递一个 "context" 到程序。 Context 可以是超时或截止日期(deadline)或通道,来指示停止运行和返回

Go 语言中的每一个请求的都是通过一个单独的 Goroutine 进行处理的,我们可能会创建多个 Goroutine 来处理一次请求,而Context的主要作用就是在不同的 Goroutine 之间同步请求特定的数据、取消信号以及处理请求的截止日期。

每一个Context都会从最顶层的 Goroutine 一层一层传递到最下层,这也是 Golang 中上下文最常见的使用方式,如果没有Contex,当上层执行的操作出现错误时,下层其实不会收到错误而是会继续执行下去但是当我们正确地使用Contex时,就可以在下层及时停掉无用的工作减少额外资源的消耗。

这其实就是 Golang 中上下文的最大作用,在不同 Goroutine 之间对信号进行同步避免对计算资源的浪费,与此同时Contex还能携带以请求为作用域的键值对信息


3.slice与array的区别和关系

区别:

array定长,slice不定长。

数组是值类型,切片是一个引用类型。但他们的传递方法都是值传递。

联系:

slice是基于数组类型做的一层封装。它非常灵活,支持自动扩容。

一个slice由三个部分构成:指针、长度和容量。指针指向第一个slice元素对应的底层数组元素的地址。长度对应slice中元素的数目;长度不能超过容量,容量一般是从slice的开始位置到底层数组的结尾位置。


4.如何排查goroutine泄露

什么是goroutine泄露

Go 中的并发性是以 goroutine(独立活动)和 channel(用于通信)的形式实现的。处理 goroutine 时,程序员需要小心翼翼地避免泄露。如果一个协程永远堵塞在 I/O 上(例如 channel 通信),或者是陷入死循环,协程就会一直消耗资源,永远无法结束,这就造成了goroutine 泄露。goroutine 泄露后,程序会使用比实际需要更多的内存,或者最终耗尽内存,从而导致崩溃。

泄露的原因大多集中在:

  • Goroutine 内正在进行 channel/mutex 等读写操作,但由于逻辑问题,某些情况下会被一直阻塞。

  • Goroutine 内的业务逻辑进入死循环,资源一直无法释放。

  • Goroutine 内的业务逻辑进入长时间等待,有不断新增的 Goroutine 进入等待。

发送不接收

func main() {
    for i := 0; i < 4; i++ {
        queryAll()
        fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
    }
}

func queryAll() int {
    ch := make(chan int)
    for i := 0; i < 3; i++ {
        go func() { ch <- query() }()
     }
    return <-ch
}

func query() int {
    n := rand.Intn(100)
    time.Sleep(time.Duration(n) * time.Millisecond)
    return n
}

输出结果:

goroutines: 3
goroutines: 5
goroutines: 7
goroutines: 9

输出的 goroutines 数量是在不断增加的,每次多 2 个。也就是每调用一次,都会泄露 Goroutine。

原因在于 channel 均已经发送了(每次发送 3 个),但是在接收端并没有接收完全(只返回 1 个 ch),所以诱发了 Goroutine 泄露。

接收不发送

func main() {
    defer func() {
        fmt.Println("goroutines: ", runtime.NumGoroutine())
    }()

    var ch chan struct{}
    go func() {
        ch <- struct{}{}
    }()
    
    time.Sleep(time.Second)
}

输出结果:

goroutines:  2

channel 接收了值,但是不发送的话,同样会造成阻塞。

nil channel

func main() {
    defer func() {
        fmt.Println("goroutines: ", runtime.NumGoroutine())
    }()

    var ch chan int
    go func() {
        <-ch
    }()
    
    time.Sleep(time.Second)
}

输出结果:

goroutines:  2

channel 如果忘记初始化,那么无论是读,还是写操作,都会造成阻塞。

正确的初始化:

    ch := make(chan int)
    go func() {
        <-ch
    }()
    ch <- 0
    time.Sleep(time.Second)

慢等待

func main() {
    for {
        go func() {
            _, err := http.Get("https://www.xxx.com/")
            if err != nil {
                fmt.Printf("http.Get err: %v\n", err)
            }
            // do something...
    }()

    time.Sleep(time.Second * 1)
    fmt.Println("goroutines: ", runtime.NumGoroutine())
 }
}

输出结果:

goroutines:  5
goroutines:  9
goroutines:  13
goroutines:  17
goroutines:  21
goroutines:  25
...

在应用程序中去调用第三方服务的接口时,有时候会很慢,久久不返回响应结果。

而Go 语言中默认的http.Client是没有设置超时时间的。

因此就会导致一直阻塞,Goroutine 自然也就持续暴涨,不断泄露,最终占满资源,导致事故。

在 Go 工程中,我们一般建议至少对http.Client设置超时时间:

    httpClient := http.Client{
        Timeout: time.Second * 15,
    }

并且要做限流、熔断等措施,以防突发流量造成依赖崩塌,造成P0事故。

互斥锁忘记解锁

func main() {
    total := 0
    defer func() {
        time.Sleep(time.Second)
        fmt.Println("total: ", total)
        fmt.Println("goroutines: ", runtime.NumGoroutine())
 }()

    var mutex sync.Mutex
    for i := 0; i < 10; i++ {
        go func() {
            mutex.Lock()
            total += 1
        }()
    }
}


//正确写法
  var mutex sync.Mutex
    for i := 0; i < 10; i++ {
        go func() {
            mutex.Lock()
            defer mutex.Unlock()
            total += 1
    }()
    }

输出结果:

total:  1
goroutines:  10

在这个例子中,第一个互斥锁sync.Mutex加锁了,但是他可能在处理业务逻辑,又或是忘记unlock了。

因此导致后面的所有sync.Mutex想加锁,却因未释放又都阻塞住了。

同步锁使用不当

func handle(v int) {
    var wg sync.WaitGroup
    wg.Add(5)
    for i := 0; i < v; i++ {
        fmt.Println("hello")
        wg.Done()
    }
    wg.Wait()
}

func main() {
    defer func() {
        fmt.Println("goroutines: ", runtime.NumGoroutine())
    }()

    go handle(3)
    time.Sleep(time.Second)
}

//正确写法
 var wg sync.WaitGroup
    for i := 0; i < v; i++ {
        wg.Add(1)
        defer wg.Done()
        fmt.Println("hello")
    }
    wg.Wait()

在这个例子中,我们调用了同步编排sync.WaitGroup模拟了一遍我们会从外部传入循环遍历的控制变量。

但由于wg.Add的数量与wg.Done数量并不匹配,因此在调用wg.Wait方法后一直阻塞等待。

排查:

使用pprof排查

1. 使用pprof查看异常情况,

2.如果是goroutine数量异常多,说明goroutine泄露

3.具体查看goroutine阻塞在哪一部分代码

4. 查看单个goroutine的信息,用调用栈以及行数作为查找参数,随意找一个goroutine查看详细信息


5.go中的内存逃逸

内存逃逸是指在函数内部申请的临时变量,本应该在(栈)上面,但由于一些特殊原因,系统将其申请在堆上的情况。

程序变量会携带有一组校验数据,用来证明它的整个生命周期是否在运行时完全可知。如果变量通过了这些校验,它就可以在栈上分配。否则就说它逃逸了,必须在堆上分配。

在函数中申请一个新的对象:

  • 如果分配 在栈中,则函数执行结束可自动将内存回收;

  • 如果分配在堆中,则函数执行结束可交给GC(垃圾回收)处理;

  1. 如果函数外部没有引用,则优先放到栈中;
  2. 如果函数外部存在引用,则必定放到堆中;

针对第一条,可能放到堆上的情形:定义了一个很大的数组,需要申请的内存过大,超过了栈的存储能力。

能引起变量逃逸到堆上的典型情况:

  • 在方法内把局部变量指针返回 局部变量原本应该在栈中分配,在栈中回收。但是由于返回时被外部引用,因此其生命周期大于栈,则逃逸。
  • 发送指针或带有指针的值到 channel 中。 在编译时,是没有办法知道哪个 goroutine 会在 channel 上接收数据。所以编译器没法知道变量什么时候才会被释放。
  • 在一个切片上存储指针或带指针的值。 一个典型的例子就是 []*string 。这会导致切片的内容逃逸。尽管其后面的数组可能是在栈上分配的,但其引用的值一定是在堆上。
  • slice 的背后数组被重新分配了,因为 append 时可能会超出其容量( cap )。 slice 初始化的地方在编译时是可以知道的,它最开始会在栈上分配。如果切片背后的存储要基于运行时的数据进行扩充,就会在堆上分配。
  • 在 interface 类型上调用方法。 在 interface 类型上调用方法都是动态调度的 —— 方法的真正实现只能在运行时知道。想像一个 io.Reader 类型的变量 r , 调用 r.Read(b) 会使得 r 的值和切片b 的背后存储都逃逸掉,所以会在堆上分配。

6.什么是野生groutine,有什么危害,如何避免

什么是野生groutine

一般程序遇到panic会崩溃,如果不捕获处理的话,线上程序遇到这情况就崩溃了,服务就挂掉了。

所以需要用recover捕获,然后执行重试或者跳过这个问题,总之不至于让程序挂掉。

但是如果开了一个协程,并且这个协程里面遇到了异常,而主协程里面的recover是捕获不到其他协程异常的,程序还是会挂掉。

这种简单一个go就开一个的协程就叫野生协程,如果里面有异常,程序就都挂了,正确的做法是每个协程里都要单独捕获它自己的异常,这样才不会挂掉。

危害:

野生groutine无法处理panic,很容易就会导致程序崩溃。

捕获goroutine中的panic

func main() {
    GoHelp(func() {
        fmt.Println("hello")
        panic("error")
    })
    time.Sleep(10 * time.Second)
}

func GoHelp(f func()) {
    go func() {
        defer func() {
            if err := recover(); err != nil {
                fmt.Println("panic in GoHelp: ", err)
            }
        }()
        f()
    }()
}
//hello
//panic in GoHelp:  goroutine error
 

被defer的函数在return 返回数据之后执行,这时刚好用来捕获函数抛出的panic。

当goroutine中引发panic时,此goroutine的所有defer都将会被执行。

运用此方法后,发现error被捕获,而进程并没有退出。

此时由野生goroutine引发的panic,就优雅的解决了。

7.如何解决map的并发安全问题

内建的map不是线程(goroutine)安全的。

这是因为map 变量为引用(指针)类型变量,并发写时,多个协程同时操作一个内存,类似于多线程操作同一个资源会发生竞争关系,共享资源会遭到破坏。为了安全,系统会自动抛出错误。

如何解决:

主要思路是通过加锁保证每个协程不同步操作map。

Go 1.9之前的解决方案

它使用嵌入struct为map增加一个读写锁。

利用读写锁而不是Mutex可以进一步减少读写的时候因为锁带来的性能损失。

Go 1.9的解决方案

Golang Map并发处理机制(sync.Map)

2021-09-02_第2张图片

8.进程、线程、协程的区别

进程是一个运行的应用程序。

进程是程序资源分配的最小单位。

进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。

每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。

由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。

一个进程下至少有一个线程,并且可以有多个线程。

线程是进程中的执行单元

线程是一种轻量级进程,是CPU调度的最小单位(线程是任务调度和执行的最小单位)。

同一进程内的线程共享资源和数据。

一个标准的线程由线程ID,PC指针,寄存器集合和堆栈组成。

线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点在运行中必不可少的资源,但它可与同属于一个进程的其他线程共享进程所拥有的全部资源。

由于同一个进程里的线程是共享资源的,所以多线程间通信一般用全局变量就行(需要加锁防止资源竞争)

线程拥有自己独立的栈和共享的堆,共享堆,不共享栈。系统线程一般都有固定的栈内存(通常为2MB)。

进程和线程的切换由操作系统调度。

协程是一种用户态的轻量级线程,协程的调度过程运行在用户态,由runtime决定。

Go 的协程依赖于线程来进行。执行效率高、占用内存少、Goroutine之间是共享内存的。

协程拥有自己的寄存器上下文和栈。

协程调度切换时,将寄存器上下文和栈保存到其他地方(MPG中的P),在切回来的时候,恢复先前保存的寄存器上下文和栈,协程调度发生在用户态而非内核态,由runtime决定,上下文的切换非常快。

直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量。

Goroutine之间是共享内存的。

一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这么大。

为什么线程切换开销比进程切换开销小:

进程的切换涉及虚拟内存空间的切换,而线程切换不涉及虚拟内存空间的切换。

多协程效率高于多线程的原因:

1.协程占用的资源远小于线程

2.协程的调度切换发生在用户态,避免了陷入内核级别的上下文切换而造成的性能损失

3.线程或进程的切换是由操作机制按照时间分片等机制来进行切换的,这种切换机制使得很多切换是没必要的,使得更多资源与时间耗费在了无谓的上下文切换上。

协程是半抢占式的协作调度,只有当协程阻塞时才会导致调度,阻塞的协程被切换出去,可运行的协程被切换进来。
 

9.new与make的区别与联系

new:分配内存,new 函数只接受一个参数,这个参数是一个类型,并且返回一个指向该类型内存地址的指针。同时 new 函数会把分配的内存置为零,也就是类型的零值。

make:make 也是用于内存分配的,但是和 new 不同,它只用于 chan、map 以及 slice 的内存创建,而且它返回的类型就是这三个类型本身,而不是他们的指针类型。(因为这三种类型就是引用类型,所以就没有必要返回他们的指针了)。


2023.3.31补充:

new也可以做slice、map、channel的内存分配,make相对于new的区别主要是make会对三种数据结构值做初始化(长度、容量等)。

10.使用go语言过程中遇到过什么坑

只有当切片有值时,才能通过下标来操作切片。

Struct、Array、Slice,map的比较

如果struct结构体的所有字段都能够使用==操作比较,那么结构体变量也能够使用==比较。 

但是,如果struct字段不能使用==比较,那么结构体变量使用==比较会导致编译错误。同样,array

只有在它的每个元素能够使用==比较时(长度相同,类型相同),array变量才能够比较。

切片之间不能比较,只能和nil比较。

Go提供了一些用于比较不能直接使用==比较的函数,reflect.DeepEqual()函数。

reflect.DeepEqual()函数对于nil值的slice与空元素的slice是不相等的,这点不同于bytes.Equal()函数。

11. 如何保证缓存一致性

1. 先更新数据库,再淘汰缓存:

实际生产中使用,但有小概率导致缓存和数据库中的数据不一致。

2021-09-02_第3张图片

读操作在更新操作进行前读取到数据,在更新操作删除缓存之后将旧数据更新到缓存。因为读操作的速度要快于写操作(更新),所以发生概率小。

为了减小这种情况造成的影响,可以为缓存设置过期时间。 

2. 先淘汰缓存,再更新数据库

采用异步更新缓存策略

A进程进行写操作,先成功淘汰缓存,但是由于网络或者其他原因,还未更新数据库或者正在更新。

B进程进行读操作,发现缓存中没有想要的数据,从数据库中读取数据,但是B线程只是从库中读取想要的数据,并不更新缓存。并不会导致缓存不一致。 

A线程更新数据库后,通过订阅binlog来异步更新缓存,这样数据库和缓存一直都是 一致的。

如果采用同步更新缓存的策略,可以采下方法。

1.串行化

保证对同一数据的读写严格按照先后顺序串行化进行,避免并发比较大的情况,多个线程对同意数据进行操作带来的缓存不一致的问题。

2.双删、设置缓存超时时间。

采用先淘汰再更新数据库的策略,如果并发比较大的情况下,在缓存失效前会存在较长时间的缓存不一致。可以采用双删策略。即更新前删除缓存,更新后也删除缓存。


强一致性和最终一致性:

如果对一致性要求很高,几乎不能用缓存

但如果不高,可以用最终一致:期间可能有短时间不一致,但一段时间后肯定能达到一致。

12.一致性hash

hash环

一致性Hash算法也是使用取模的方法,不过,一般的hash算法取模是对服务器的数量进行取模,而一致性的Hash算法是对2的32方取模。即,一致性Hash算法将整个Hash空间组织成一个虚拟的圆环,Hash函数的值空间为0 ~ 2^32 - 1(一个32位无符号整型)

hash原理

将数据Key使用相同的函数Hash计算出哈希值,并确定此数据在环上的位置,从此位置沿环顺时针查找,遇到的服务器就是其应该定位到的服务器。

例如,现在有ObjectA,ObjectB,ObjectC三个数据对象,经过哈希计算后,在环空间上的位置如下:


2021-09-02_第4张图片

hash一致性的容错

现在,假设Node C宕机了,A、B不会受到影响,只有Object C对象被重新定位到Node A。

在一致性Hash算法中,如果一台服务器不可用,受影响的数据仅仅是此服务器到其环空间前一台服务器之间的数据(这里为Node C到Node B之间的数据),其他不会受到影响。

2021-09-02_第5张图片


当系统增加了一台服务器Node X。

此时对象ObjectA、ObjectB没有受到影响,只有Object C重新定位到了新的节点X上。

2021-09-02_第6张图片


一致性Hash算法对于节点的增减都只需重定位环空间中的一小部分数据,有很好的容错性和可扩展性。

13.布隆过滤器

bloom算法类似一个hash set,用来判断某个元素(key)是否在某个集合中。

和一般的hash set不同的是,这个算法无需存储key的值,对于每个key,只需要k个比特位,每个存储一个标志,用来判断key是否在集合中。

算法:

1. 首先需要k个hash函数,每个函数可以把key散列成为1个整数

2. 初始化时,需要一个长度为n比特的位图,每个比特位初始化为0

3. 某个key加入集合时,用k个hash函数计算出k个散列值,并把数组中对应的比特位置为1

4. 判断某个key是否在集合时,用k个hash函数计算出k个散列值,并查询位图中对应的比特位,如果所有的比特位都是1,认为在可能集合中。

2021-09-02_第7张图片

与hash比较

优点:

  • 不需要存储key,节省空间

缺点:

  • 布隆过滤器只能判断某样东西一定不存在或者可能存在。
  • 无法删除。

布隆过滤器长度

过小的布隆过滤器很快所有的 bit 位均为 1,那么查询任何值都会返回“可能存在”,起不到过滤的目的了。布隆过滤器的长度会直接影响误报率,布隆过滤器越长其误报率越小。

 哈希函数个数

哈希函数的个数也需要权衡,个数越多则布隆过滤器 bit 位置位 1 的速度越快,且布隆过滤器的效率越低;但是如果太少的话,那我们的误报率会变高。

散列函数的选择

hash函数的选择和数据特征不合适

一批数据经过一个hash函数运算,如果散列不均匀,数据都堆到一起了,误报率会变高。

14. 如何解决hash碰撞(go map与redis分别选择什么方案)

哈希查找表一般会存在“碰撞”的问题,就是说不同的 key 被哈希到了同一个 bucket。

一般有四种应对方法:链表法、开放地址法、再哈希法、建立公共溢出区。

链地址法将一个 bucket 实现成一个链表,落在同一个 bucket 中的 key 都会插入这个链表。

开放地址法则是碰撞发生后,通过一定的规律,在数组的后面挑选“空位”,用来放置新的 key。

再哈希法:同时构造多个不同的哈希函数。

将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。

go采用链地址法:

每一个bucket里面的溢出指针 会指向另外一个 bucket,每一个bucket里面存放的是 8 个 key 和 8 个 value ,bucket 里面的溢出指针又指向另外一个bucket,用类似链表的方式将他们连接起来。

2021-09-02_第8张图片

 2021-09-02_第9张图片

 redis采用链地址法解决:

2021-09-02_第10张图片

 每个哈希表节点都有一个next指针, 多个哈希表节点可以用next指针构成一个单向链表, 被分配到同一个索引上的多个节点可以用这个单向链表连接起来, 这就解决了键冲突的问题。

15.布隆过滤器不能删除数据?

当想要删除某个key时,需要将他的所有位都置为0,因为位数组上的某一位有可能被多个 key 所公用,所以删除会影响到其他元素。

16.布谷鸟过滤器(布隆过滤器删除数据)

原理

布谷鸟过滤器源于布谷鸟Hash算法,布谷鸟Hash算法并不是使用位图实现的, 而是一维数组.。它所存储的是数据的指纹(fingerprint),是对数据的压缩(只用来代表数据)。

当有新的数据插入的时候,它会用两个hash函数计算出这个数据在数组中对应的两个位置,这个数据一定会被存在这两个位置之一。如果两个位置中有一个位置位空, 那么就可以将元素直接放进去. 但是如果这两个位置都满了, 它就会随机踢走一个, 然后自己霸占了这个位置。

被踢出的数据就去另一个hash算法找对应的位置,通过不断的踢出数据,最终所有数据都找到了自己的归宿。

优化

直到所有位置都占满,这代表布谷Hash表走到了极限,需要将Hash算法优化(增加hash函数)或者扩容:①在一个位置上再设置四个位置,可以理解为小数组,这样的好处是数据连续,容易查询,但空间利用率下降 ② 给数组扩容。

2021-09-02_第11张图片

两个hash算法的机制

由于布谷鸟过滤器在踢出数据时,要再次计算原数据在另一个Hash函数的值,因此设计Hash算法时将两个Hash函数变成了一个Hash函数,一个hash函数的备选位置是Hash(x),另一个hash函数的备选位置是Hash(x)⊕hash(fingerprint(x)),即第一个hash函数的位置与存储的指纹的Hash值做异或运算。这样可以直接用指纹的值 异或 原来位置的Hash值来计算出其另一个位置。

挤兑循环:

hash函数选择不好,数据集中到了一起,导致一直循环踢出。当踢出操作进行多次以后,会停止踢出,进行优化(上面的)。

删除:

直接删除指纹即可。

缺陷:

一个指纹占一个字节,8个bit位,有256种可能,如果数据特别多的情况下,可能出现一摸一样的指纹,如果他们用hash函数计算出的位置也一样,布谷鸟过滤器就会将他们视为一个数据,查询会出现误查询,删除的时候也可能误删数据。所以布谷鸟过滤器也存在误差。


布隆过滤器和布谷鸟过滤器都是牺牲精度换取空间。

17.进程间通信的方式与进程间同步方式

进程间同步方式:

临界区、互斥区、信号量(pv操作)、事件

进程间通信方式:

管道pipe:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。

命名管道FIFO:有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。

消息队列MessageQueue:

消息队列其实就是一个消息的链表,是一系列保存在内核中消息的列表。用户进程可以向消息队列添加消息,也可以向消息队列读取消息。

消息队列与管道相比,其优势是对每个消息指定特定的消息类型,接收的时候不需要按照队列次序进行接收,而是可以根据自定义条件接收特定的消息。

共享存储SharedMemory:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。

2021-09-02_第12张图片

 2021-09-02_第13张图片

 上述两种模型在操作系统中都常见,而且许多系统也实现了这两种模型。

消息队列对于交换较少数量的数据很有用,因为无需避免冲突。对于分布式系统,消息队列也比共享内存更易实现。

共享内存可以快于消息队列,这是因为消息队列的实现经常采用系统调用,因此需要消耗更多时间以便内核介入。而共享内存系统仅在建立共享内存区域时需要系统调用;一旦建立共享内存,所有访问都可作为常规内存访问,无需借助内核。

信号量Semaphore:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。

套接字Socket:套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信。

信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
 

18.孤儿进程和僵尸进程

孤儿进程:父进程结束,子进程就成为孤儿进程,会由1号进程(init进程——linux启动的第一个启动的进程)领养。

僵尸进程:进程结束但是没有完全释放内存(在内核中的task_struct没有释放),该进程就会成为僵尸进程。当僵尸进程的父进程结束后(变为孤儿僵尸进程)就会被init进程领养,最终被回收。

19.Redis的基本数据结构

  • 字符串类型 string  底层:字符串
  • 哈希类型 hash       底层: 数据少时是压缩列表,数据多时是散列表
  • 列表类型 list   按照链表的顺序存储数据,可以在链表头和尾操作 
    底层:数据少时是压缩列表(一方面比较节省内存,另一方面可以支持不同类型数据的存储),数据多时是双向循环链表。
  • 集合类型 set  集合内元素不重复 
    底层:当数据数量较小且都是整数时,使用有序数组,否则使用散列表
  • 有序集合类型zset  集合内元素有序且不重复,适合做排行榜
    底层:跳跃表

20.Redis为什么那么快

  1. Redis是完全基于内存的,绝大部分请求是纯粹的内存操作,避免了磁盘I/O等耗时操作。
  2. 数据结构简单,对数据操作也简单,Redis中的数据结构是专门进行设计的。
  3. 采用单线程,避免了不必要的上下文切换和竞争条件,不用去考虑各种锁的问题。
  4. 采用了I/O多路复用机制,大大提升了并发效率。 

这里的I/O是网络I/O, 多路指的是多个TCP连接(如Socket),复用指的是复用一个线程。

采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),且 Redis 在内存中操作数据的速度非常快。

为什么要使用单线程 ?

首先高性能的服务器不一定是多线程的,多线程(CPU上下文会切换会消耗cpu资源) 不一定比单线程效率高。  

redis数据存放在内存上,对内存系统来说,多次读写都是在一个CPU上,上下文切换非常影响效率,所以用单线程以避免CPU上下文切换。并且在计算机中cpu的处理速度远大于内存的速度,cpu不会成为性能的瓶颈。(现在版本redis支持多线程)。

21.GC

垃圾回收Garbage Collection,是编程语言中提供的内存管理功能。

在传统的系统级编程语言(主要指C/C++)中,程序员定义了一个变量,就是在内存中开辟了一段相应的空间来存值,当程序不再需要使用某个变量的时候,就需要销毁该对象并释放其所占用的内存资源,好重新利用这段空间。

在C/C++中,释放无用变量内存空间的事情需要由程序员自己来处理。就是说当程序员认为变量没用了,就手动地释放其占用的内存。

后来的语言释放无用变量内存空间的事情系统会自己做。

垃圾回收常见的方法

引用计数

引用计数通过在对象上增加自己被引用的次数,被其他对象引用时加1,引用自己的对象被回收时减1,引用数为0的对象即为可以被回收的对象。这种算法在内存比较紧张和实时性比较高的系统中使用的比较广泛

优点:

1、方式简单,回收速度快。

缺点:

1、需要额外的空间存放计数。

2、无法处理循环引用(如a.b=b;b.a=a这种情况)。

3、频繁更新降低了引用计数的性能。

标记-清除

该方法分为两步,标记从根变量开始迭代得遍历所有被引用的对象,对能够通过应用遍历访问到的对象都进行标记为“被引用”;标记完成后进行清除操作,对没有标记过的内存进行回收(回收同时可能伴有碎片整理操作)。

这种方法解决了引用计数的不足,但是也有比较明显的问题:每次启动垃圾回收都会暂停当前所有的正常代码执行,回收使得系统响应能力大大降低!当然后续也出现了很多mark&sweep算法的变种(如三色标记法)优化了这个问题。

三色标记算法

三色标记算法是对标记阶段的改进,原理如下:

  1. 起初所有对象都是白色。
  2. 从根出发扫描所有可达对象,标记为灰色,放入待处理队列。
  3. 从队列取出灰色对象,将其引用对象标记为灰色放入队列,自身标记为黑色。
  4. 重复 3,直到灰色对象队列为空。此时白色对象即为垃圾,进行回收。

可视化如下。

2021-09-02_第14张图片

三色标记的一个明显好处是能够让用户程序和 mark 并发的进行。

复制收集

复制收集的方式只需要对对象进行一次扫描。准备一个「新的空间」,从根开始,对对象进行扫描,如果存在对这个对象的引用,就把它复制到「新空间中」。一次扫描结束之后,所有存在于「新空间」的对象就是所有的非垃圾对象。

标记清除的方式节省内存但是两次扫描需要更多的时间,对于垃圾比例较小的情况占优势。

复制收集更快速但是需要额外开辟一块用来复制的内存,对垃圾比例较大的情况占优势。特别的,复制收集有「局部性」的优点。

在复制收集的过程中,会按照对象被引用的顺序将对象复制到新空间中。于是,关系较近的对象被放在距离较近的内存空间的可能性会提高,这叫做局部性。局部性高的情况下,内存缓存会更有效地运作,程序的性能会提高。

对于标记清除,有一种标记压缩算法的衍生算法:

对于压缩阶段,它的工作就是移动所有的可达对象到堆内存的同一个区域中,使他们紧凑的排列在一起,从而将所有非可达对象释放出来的空闲内存都集中在一起,通过这样的方式来达到减少内存碎片的目的。

分代收集(generation)

这种收集方式用了程序的一种特性:大部分对象会从产生开始在很短的时间内变成垃圾,而存在的很长时间的对象往往都有较长的生命周期。

根据对象的存活周期不同将内存划分为新生代和老年代,存活周期短的为新生代,存活周期长的为老年代。这样就可以根据每块内存的特点采用最适当的收集算法。

新创建的对象存放在称为 新生代中(一般来说,新生代的大小会比 老年代小很多)。高频对新生成的对象进行回收,称为「小回收」,低频对所有对象回收,称为「大回收」。每一次「小回收」过后,就把存活下来的对象归为老年代,「小回收」的时候,遇到老年代直接跳过。大多数分代回收算法都采用的「复制收集」方法,因为小回收中垃圾的比例较大。

写屏障

这种方式存在一个问题:如果在某个新生代的对象中,存在「老生代」的对象对它的引用,它就不是垃圾了,那么怎么制止「小回收」对其回收呢?这里用到了一中叫做写屏障的方式。

程序对所有涉及修改对象内容的地方进行保护,被称为「写屏障」(Write Barrier)。写屏障不仅用于分代收集,也用于其他GC算法中。

在此算法的表现是,用一个记录集来记录从新生代到老生代的引用。如果有两个对象A和B,当对A的对象内容进行修改并加入B的引用时,如果①A是「老生代」②B是「新生代」。则将这个引用加入到记录集中。「小回收」的时候,因为记录集中有对B的引用,所以B不再是垃圾。

GO的垃圾回收器

go语言垃圾回收总体采用的是经典的mark and sweep算法。

  • v1.3以前版本 STW(Stop The World)

    go runtime在一定条件下(内存超过阈值或定期如2min),暂停所有任务的执行,进行mark&sweep操作,操作完成后再启动所有任务的执行。当时解决这个问题比较常用的方法是尽快控制自动分配内存的内存数量以减少gc负荷,同时采用手动管理内存的方法处理需要大量及高频分配内存的场景。

  • v1.3 Mark STW, Sweep 并行

    go runtime分离了mark和sweep操作,先暂停所有任务执行并启动mark,mark完成后马上就重新启动被暂停的任务,让sweep任务和普通协程任务一样并行的和其他任务一起执行。如果运行在多核处理器上,go会试图将gc任务放到单独的核心上运行而尽量不影响业务代码的执行。

  • v1.5 三色标记法

    go 1.5正在实现的垃圾回收器是“非分代的、非移动的、并发的、三色的标记清除垃圾收集器”。引入了上文介绍的三色标记法,这种方法的mark操作是可以渐进执行的而不需每次都扫描整个内存空间,可以减少stop the world的时间

  • v1.8 混合写屏障(hybrid write barrier)

    这个版本的 GC 代码采用一种混合的 write barrier 方式来避免堆栈重新扫描。

    混合屏障的优势在于它永久地使堆栈变黑(没有STW并且没有写入堆栈的障碍),这完全消除了堆栈重新扫描的需要,从而消除了对堆栈屏障的需求。重新扫描列表。特别是堆栈障碍在整个运行时引入了显着的复杂性,并且干扰了来自外部工具(如GDB和基于内核的分析器)的堆栈遍历。

    此外,与Dijkstra风格的写屏障一样,混合屏障不需要读屏障,因此指针读取是常规的内存读取; 它确保了进步,因为物体单调地从白色到灰色再到黑色。

    混合屏障的缺点很小。它可能会导致更多的浮动垃圾,因为它会在标记阶段的任何时刻保留从根(堆栈除外)可到达的所有内容。然而,在实践中,当前的Dijkstra障碍可能几乎保留不变。混合屏障还禁止某些优化:特别是,如果Go编译器可以静态地显示指针是nil,则Go编译器当前省略写屏障,但是在这种情况下混合屏障需要写屏障。这可能会略微增加二进制大小。

小结:

通过go team多年对gc的不断改进和优化,GC的卡顿问题在1.8 版本基本上可以做到 1 毫秒以下的 GC 级别。 实际上,gc低延迟是有代价的,其中最大的是吞吐量的下降。由于需要实现并行处理,线程间同步和多余的数据生成复制都会占用实际逻辑业务代码运行的时间。GHC的全局停止GC对于实现高吞吐量来说是十分合适的,而Go则更擅长与低延迟。 

并行GC的第二个代价是不可预测的堆空间扩大。程序在GC的运行期间仍能不断分配任意大小的堆空间,因此我们需要在到达最大的堆空间之前实行一次GC,但是过早实行GC会造成不必要的GC扫描,这也是需要衡量利弊的。因此在使用Go时,需要自行保证程序有足够的内存空间。

垃圾收集是一个难题,没有所谓十全十美的方案,通常是为了适应应用场景做出的一种取舍。

22. 跳跃表(Redis数据结构)

什么是跳跃表

对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 O(n)。

跳跃表是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的(类似有序数组二分查找的效果)。

如何实现跳跃表

可以通过在链表上建索引的方式。每两个结点提取一个结点到上一级,我们把抽出来的那一级叫作索引。

2021-09-02_第15张图片

 假设要查找节点8,先在索引层遍历,当遍历到索引层中值为 7 的结点时,发现下一个节点是9,那么要查找的节点8肯定就在这两个节点之间。

下降到链表层继续遍历就找到了8这个节点。

原先在单链表中找到8这个节点要遍历8个节点,而现在有了一级索引后只需要遍历五个节点。

从这个例子里,可以看出,加来一层索引之后,查找一个结点需要遍的结点个数减少了,也就是说查找效率提高了,同理可以增加多层索引,注意索引层节点数要大于2。

2021-09-02_第16张图片

  像这种链表加多级索引的结构,就是跳跃表!

跳跃表的维护

跳跃表索引的增加

当新数据不断插入到连接,索引层的索引节点会逐渐不够用,这时候就需要从下层“提拔”一批索引,这一步骤不可预测,所以采用“抛硬币”的方法让大体趋于均匀:即新数据插入到链表以后,会有1/2的概率“提拔”到上一层索引,接着又有1/2的概率再上一层...以此类推。

跳跃表元素的删除

当有元素要删除时,会自上而下,查找第一次出现节点的索引,并逐层找到每一层对应的节点。

删除每一层查找到的节点,如果该层只剩下1个节点,删除整个一层(原链表除外)。

跳跃表与二叉树查找

跳跃表维持平衡的成本比较低,完全靠随机,而二叉树则需要Rebalance重新调整结构平衡。

23.LRU缓存淘汰

LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。

2021-09-02_第17张图片

  1. 新数据插入到链表头部;
  2. 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;
  3. 当链表满的时候,将链表尾部的数据丢弃。

过程如下:

2021-09-02_第18张图片

  1. 最开始时,内存空间是空的,因此依次进入A、B、C是没有问题的
  2. 当加入D时,就出现了问题,内存空间不够了,因此根据LRU算法,内存空间中A待的时间最为久远,选择A,将其淘汰
  3. 当再次引用B时,内存空间中的B又处于活跃状态,而C则变成了内存空间中,近段时间最久未使用的
  4. 当再次向内存空间加入E时,这时内存空间又不足了,选择在内存空间中待的最久的C将其淘汰出内存,这时的内存空间存放的对象就是E->B->D

24. GRPC和HTTP的区别

http是一种协议,rpc是一种接口提供方式

RPC和HTTP的联系

RPC跟HTTP不是对立面,RPC中可以使用HTTP作为通讯协议(也可以使用TCP来作为通讯协议)

RPC是一种设计、实现框架,通讯协议只是其中一部分。

RPC的本质是提供了一种轻量无感知的通信的方式,在分布式机器上调用其他方法与本地调用无异(远程调用的过程是透明的,你并不知道这个调用的方法是部署在哪里,通过PRC能够解耦服务)。同时http协议依赖于服务器ip地址和端口号,耦合性很高。

HTTP和RPC的对比

协议

1.RPC:可以基于TCP实现,也可以基于HTTP实现

2.HTTP:基于HTTP协议

报文长度
1.RPC:自定义具体实现可以减少很多无用的报文内容,使得报文体积更小

2.HTTP:如果是HTTP1.1,它的报文中有很多内容都是无用的。如果是HTTP2.0以后和RPC相差不大,缺少的是RPC的一些服务治理功能。所以效率差主要还是基于HTTP1.1来说。

连接方式

1.RPC:建立长连接,减少网络消耗。

2.HTTP:每次连接都是三次握手

序列化传输

1.RPC可以基于很多种序列化方式。如:protobuf和thrift

2.HTTP主要通过JSON,序列化和反序列化,这样的效率较低

调用函数

http需要依赖一个文档才能知道函数需要什么参数,会返回什么,而文档和http协议是分开维护的,如果文档更新不及时,也会出错。

rpc(grpc)有自己的描述文件,可以直观的了解函数的情况。

RPC是根据语言的API来定义的,而不是基于网络的应用来定义的,调用更方便,协议私密更安全、内容更小效率更高。(根据语言API定义?不懂)

小结:
RPC一般支持微服务框架丰富的治理功能,更适合企业内部的接口调用。而HTTP更适合多平台之间的相互调用

GRPC

 grpc采用Protobuf生成高效的二进制编码,Protobuf的压缩率是非常高的;这使得它比 JSON/HTTP 快很多。

 grpc采用http2协议,http2相较于http有更多的优势:

1. 多路复用

2. 头部压缩

3. 二进制分帧

4. 服务器主动推送资源

大白话

HTTP 与 RPC 的关系就好比普通话与方言的关系。

要进行跨企业服务调用时,往往都是通过 HTTP API,也就是普通话,虽然效率不高,但是通用,没有太多沟通的学习成本。但是在企业内部还是 RPC 更加高效,同一个企业公用一套方言进行高效率的交流,要比通用的 HTTP 协议来交流更加节省资源。

整个中国有非常多的方言,正如有很多的企业内部服务各有自己的一套交互协议一样。虽然国家一直在提倡使用普通话交流,但是这么多年过去了,你回一趟家乡探个亲什么的就会发现身边的人还是流行说方言。

RPC主要用于公司内部的服务调用,性能消耗低,传输效率高,服务治理方便。HTTP主要用于对外的异构环境,浏览器接口调用,APP接口调用,第三方接口调用等。


25.HTTP和HTTPS

HTTP:HTTP是超文本传输协议,以明文方式发送内容,不提供任何方式的数据加密,不适合传输一些敏感信息。

HTTPS: HTTPS是安全套接字层超文本传输协议,为了数据传输的安全,HTTPS在HTTP的基础上加入了SSL协议,SSL依靠证书来验证服务器的身份,并为浏览器和服务器之间的通信加密。

http和https使用的是完全不同的连接方式,用的端口也不一样,前者是80,后者是443。

http的连接很简单,是无状态的;HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议,比http协议安全。

HTTPS对比HTTP主要实现了身份认证数据加密以及信息完整性验证。

利用非对称加密实现身份认证和密钥协商,对称加密算法采用协商的密钥对数据加密,基于散列函数验证信息的完整性

HTTPS作用:

1.验证服务端身份(不容易被伪装)

通过第三方权威机构颁发的CA证书(第三方机构用自己的私钥对服务器的公钥进行数字签名)

客户端在收到服务器的证书后,用第三方机构的公钥对数字签名验证,验证通过则认为服务器是安全的。

那么第三方机构的公钥如何传输到客户机呢?

一般操作系统和浏览器在发布的时候就会将权威机构的公钥预置

所以要谨慎安装第三方打包的操作系统和浏览器

因为它们可能预置了第三方的根证书

数字证书也可以自己制作,例如12306

但因为没有第三方验证,浏览器会报不安全

当然也就失去了验证服务端身份的作用(少了一个公证人的公证,不能保证绝对安全)

2.对传输的信息加密(不易被监听)

用来传输大量信息的对称加密密钥是用非对称加密进行加密的

固在传输过程中避免被人解惑

(监听到的是密文)

3.对传输的内容进行数字签名(防篡改)

对要传输的内容用摘要算法得出内容的数字指纹

再将数字指纹和内容都加密后传给对方

对方解密后用同样的方法对内容进行签名

如果签名后的数字指纹和传过来的一样

就证明没有被篡改

HTTPS工作原理:

HTTPS采用不对称加密JK+对称加密B

服务器将不对称密钥K交给浏览器(客户端),客户端给服务器一个对称密钥B,之后就通过这个B来进行加密通信。

证书由权威机构用私钥加密,客户端用权威机构发布的公钥来解密,获得服务端发来的公钥,再用服务端公钥加密一个对称密钥,服务端收到这个对称密钥之后,就用这个对称密钥来与客户端通信。

2021-09-02_第19张图片

1.服务端配置数字证书

采用https协议的服务器必须要有一套数字证书

可以自己制作,也可以向组织申请

(区别就是自己制作的证书需要通过客户端验证通过,才能继续访问)

这套证书其实就是一对公钥和私钥

2.客户端发起https请求

用户在浏览器输入一个https网址,然后连接到服务器的443端口

请求内容包括:

1.客户端支持的协议版本(比如TLS1.0)

2.客户端支持的加密算法

3.客户端支持的加密方法

3.服务端传送证书给客户端

服务端传送公钥给客户端

其中还包含许多信息,

如证书颁发机构、过期时间等

4.客户端解析验证证书

1.首先验证公钥是否有效

(比如颁发机构、过期时间等)

如发现异常,则会弹出证书存在问题的警告框

2.验证对方是否为证书的合法持有者

(也就是验证对方是否持有证书验证签名)

如果验证通过,或未通过但用户同意,则生成一个对称加密的密钥

(即之后用于加密大量通讯数据的密钥)

然后用证书(访问端的公钥)对改随机值进行加密

5.客户端传送对称加密密钥给服务端

将用证书加密后的密钥传给服务器

(那么以后客户端和服务端的通信就可以通过这个随机值来加解密了)

6.服务端解密信息

服务端用私钥解密后,得到客户端传过来的对称加密密钥

到此为止服务器和客户端已经用非对称加密的方法协商好了一个

对称加密的密钥,那么后期的通讯就可以用该非对称加密的密钥

进行加密解密

因为非对称加密的复杂度高,影响性能,

所以后期要换做对称加密

26.HTTP1/ 1.1/ 2/ 3 

HTTP协议是HyperText Transfer Protocol(超文本传输协议)的缩写,它是互联网上应用最为广泛的一种网络协议。所有的WWW文件都必须遵守这个标准。伴随着计算机网络和浏览器的诞生,HTTP1.0也随之而来,处于计算机网络中的应用层.

HTTP1

HTTP/1.0引入了请求头响应头

同时也引入了状态码,为了减轻服务器的压力,提供了Cache机制。服务器需要统计客户端的基础信息,加入了用户代理字段

HTTP1.1

支持长连接

请求头中增加connection: keep-alive支持,针对同一个tcp进行复用,减少tcp握手带来的时间。

缓存处理

缓存头中提供了更多的属性来支持不同的缓存策略。在HTTP1.0中主要使用header里的If-Modified-Since,Expires来做为缓存判断的标准,HTTP1.1则引入了更多的缓存控制策略例如Entity tag,If-Unmodified-Since, If-Match, If-None-Match等更多可供选择的缓存头来控制缓存策略。

增加对虚拟主机的支持(Host头处理

HTTP/1.0中每个域名都只绑定唯一的IP地址,因此一个服务器只能支持一个域名。

但是随着虚拟主机技术的发展,一台物理主机上绑定多个虚拟主机的需求大大提升,每个虚拟主机都有自己单独的域名,这些单独的域名都公用同一个IP地址。

因此,请求头中也增加了Host字段,表示当前的域名地址,服务器可根据不同的Host值做不同的处理,支持同一个IP下的不同服务器提供服务。

错误通知的管理

在HTTP1.1中新增了24个错误状态响应码,如409(Conflict)表示请求的资源与资源的当前状态发生冲突;410(Gone)表示服务器上的某个资源被永久性的删除。

带宽优化及网络连接的使用

HTTP1.0中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能。

HTTP1.1则在请求头引入了range头域,它允许只请求资源的某个部分,即返回码是206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。

SPDY:是HTTP1.x的优化,针对HTTP1.x的增强,工作在SSL层之上、HTTP层之下

SPDY的方案优化了HTTP1.X的请求延迟,解决了HTTP1.X的安全性,具体如下:

  1. 降低延迟,针对HTTP高延迟的问题,SPDY优雅的采取了多路复用。多路复用通过多个请求stream共享一个tcp连接的方式,解决了HOL blocking的问题,降低了延迟同时提高了带宽的利用率。

  2. 请求优先级。多路复用带来一个新的问题是,在连接共享的基础之上有可能会导致关键请求被阻塞。SPDY允许给每个request设置优先级,这样重要的请求就会优先得到响应。比如浏览器加载首页,首页的html内容应该优先展示,之后才是各种静态资源文件,脚本文件等加载,这样可以保证用户能第一时间看到网页内容。

  3. header压缩。前面提到HTTP1.x的header很多时候都是重复多余的。选择合适的压缩算法可以减小包的大小和数量。

  4. 基于HTTPS的加密协议传输,大大提高了传输数据的可靠性。

  5. 服务端推送,采用了SPDY的网页,例如我的网页有一个sytle.css的请求,在客户端收到sytle.css数据的同时,服务端会将sytle.js的文件推送给客户端,当客户端再次尝试获取sytle.js时就可以直接从缓存中获取到,不用再发请求了。SPDY构成图:

2021-09-02_第20张图片

HTTP2

HTTP2是基于SPDY设计的,是SPDY的升级版。

HTTP2.0和HTTP1.X相比的新特性

  • 新的二进制格式,HTTP1.x的解析是基于文本。基于文本协议的格式解析存在天然缺陷,文本的表现形式有多样性,要做到健壮性考虑的场景必然很多,二进制则不同,只认0和1的组合。基于这种考虑HTTP2.0的协议解析决定采用二进制格式,实现方便且健壮。

  • 二进制分帧,在二进制分帧层上,HTTP 2.0 会将所有传输的信息分割为更小的消息和帧,并对它们采用二进制格式的编码 。

    这样分帧以后这些帧就可以乱序发送,然后根据每个帧首部的流标识符号进行组装。

  • 多路复用,同域名下多个通信可以在单个连接上完成;单个连接可以承载任意数量的双向数据流;数据流以消息的形式发送,而消息又由一个或多个帧组成,多个帧之间可以乱序发送,最后根据帧首部的流标识可以重新组装某个请求任务耗时严重,不会影响到其它连接的正常执行。

  • header压缩,如上文中所言,对前面提到过HTTP1.x的header带有大量信息,而且每次都要重复发送,HTTP2.0使用encoder来减少需要传输的header大小,通讯双方各自cache一份header fields表,既避免了重复header的传输,又减小了需要传输的大小。

  • 服务端推送 ,同SPDY一样,HTTP2.0也具有server push功能。

HTTP2.0和SPDY的区别:

  1. HTTP2.0 支持明文 HTTP 传输,而 SPDY 强制使用 HTTPS。(HTTP不加密)

  2. HTTP2.0 消息头的压缩算法采用 HPACK ,而非 SPDY 采用的 DEFLATE 。

HTTP3

由于HTTP1 HTTP1.x HTTP2都是基于TCP开发的,其中的TCP握手问题就无法避免,为了解决这个问题,Google 就另起炉灶搞了一个基于 UDP 协议的 QUIC 协议,并且使用在了 HTTP/3 上。其特点主要为:

  • 0-RTT,RRT(Round Trip Time)即客户端与服务器之间通讯来回一次所花费的时间,通过QUIC协议可以实现在客户端向服务端发起一次QUIC握手的同时即可完成验证及进行数据的传输,实现了0-RRT。
  • 多路复用,基于UDP实现的多路复用,不存在TCP中的阻塞现象。
  • 加密认证的报文,所有报文头经过了验证,所有报文body经过加密,提高安全性。
  • 向前纠错机制,在传输过程中每个数据包会冗余其他数据其他少量的数据包数据,如果一个包出现了丢包的情况,可以通过其他数据包的数据恢复,在一定程度上降低了错误重传导致的开销。



27. go map扩容

扩容的类型

根据扩容的原因不同,map扩容可以分为两类型:等量扩容双倍扩容。

双倍扩容:触发load factor的最大值,负载因子已达到当前界限。

等量扩容(不改变大小):溢出桶 overflow buckets 过多。扩容后的buckets 的数量和原来是一样的,说明可能是空kv占据的坑太多了,通过map扩容做内存整理。

负载因子 load factor,用途是评估哈希表当前的时间复杂度,其与哈希表当前包含的键值对数、桶数量等相关。

如果负载因子越大,则说明空间使用率越高,但产生哈希冲突的可能性更高。

而负载因子越小,说明空间使用率低,产生哈希冲突的可能性更低。

溢出桶 overflow buckets 的判定与 buckets 总数和 overflow buckets 总数相关联。

内存的申请

新申请的扩容空间都是预分配,等真正使用的时候才会初始化。

扩容完毕后(预分配),不会马上就进行迁移。

而是采取增量扩容的方式,当有访问到具体 bukcet 时,才会逐渐的进行迁移(将 oldbucket 迁移到 bucket)。

既然迁移是逐步进行的。那如果在途中又要扩容了,怎么办?

结合上下文可得若正在进行扩容,就会不断地进行迁移。待迁移完毕后才会开始进行下一次的扩容动作。

28.Unix下的IO模型

程序写在用户态,用户态是不知道io状态的,只能通过对内核态的函数调用来获取这些信息。

模型的产生

对于一次IO访问,数据会被先拷贝到操作系统内核缓冲区

然后再从内核缓冲区拷贝到应用程序的地址空间

所以当一个read操作发生时,会经历两个阶段

1.等待数据准备(操作系统先将IO的数据缓存在文件系统的缓存页(page cache)中)

2.将数据从内核缓冲区拷贝到应用程序的地址空间。

正因为这两个阶段,Linux系统产生了5种网络模式的方案:

阻塞式IO:

用户进程调用recvfrom时,kernel开始准备数据,用户进程则阻塞等待

当等到kernel准备好数据后,再将数据从kernel拷贝到用户空间内存

然后kernel返回结果,用户进程才能解除block的状态重写运行起来

非阻塞式IO:

当用户进程进行read操作时,若kernel中数据还未准备好,

那么并不会block用户进程,而是立即返回一个error

用户判断是error,就知道没准备好,直到准备好,再调用read时

就可以把kernel中数据拷贝到用户内存空间啦

IO多路复用:select、poll、epoll

当用户调用了select后,整个进程会被block,select会将要监视的文件描述符集传递给内核(用户态到内核态)

kernel会监视所有kernel负责的socket,并进行轮询。

当任何一个或多个socket中的数据准备好了,select就会返回整个文件描述符集给用户态

用户进程再轮询文件描述符集,找到就绪的文件,调用read操作,将数据从kernel拷贝到用户进程

优点:可以同时处理多个connection

缺点:需要两个系统调用,select和recvfrom

所以如果连接量不大的话,多线程阻塞模型更高效

信号驱动式IO:

当用户调用了read后会立即返回,通知内核监视指定的文件,当数据准备就绪时,内核会给用户进程发送一个signal,告诉它可以开始拷贝操作了。

异步IO:

用户发起read操作后,会立即返回

kernel等待数据准备完成,将数据拷贝到用户内存空间

然后kernel会给用户进程发送一个signal,告诉它read操作完成。

同步&异步-阻塞&非阻塞:

同步与异步是针对应用程序与内核的交互而言的,关注的是程序之间的协作关系。

阻塞与非阻塞更关注的是单个进程的执行状态。


同步是指:当程序1调用程序2时,程序1停下不动,直到程序2完成回到程序1来,程序1才继续执行下去。  
异步是指:当程序1调用程序2时,程序1径自继续自己的下一个动作,不受程序2的影响。

同步是指:发送方发出数据后,等接收方发回响应以后才发下一个数据包的通讯方式。  
异步是指:发送方发出数据后,不等接收方发回响应,接着发送下个数据包的通讯方式。


阻塞调用是指调用结果返回之前,当前线程会被挂起函数只有在得到结果之后才会返回。

有人也许会把阻塞调用和同步调用等同起来,实际上他是不同的。对于同步调用来说,很多时候当前线程还是激活的,只是从逻辑上当前函数没有返回而已。(同步和阻塞是两个层面的东西,不做比较)

非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。


真正的异步IO需要CPU的深度参与,只有用户线程在操作IO的时候根本不去考虑IO的执行,全部交给CPU完成,自己的执行完全不受其他程序的影响,只等待一个完成信号的时候,才是真正的异步IO。

所以拉一个子线程去轮询或使用select、poll、epool都不是异步。

IO多路复用

select、poll、epoll都是IO多路复用机制

IO多路复用就是通过一种机制,可以让一个进程监视多个文件描述符(一个进程通过调用内核来实现监视,实质上是内核在监视文件io情况),一旦某个描述符就绪,就能通知程序进行相应的读写操作。

它们本质上都是同步IO,因为它们都需要在读写事件就绪后自己负责读写(从内核空间拷贝到用户空间)也就是说这个读写过程是阻塞的。

异步IO则无需自己负责进行读写,异步IO的实现会负责把数据从内核空间拷贝到用户空间后,才通知用户进程进行处理。

select==>时间复杂度O(n)

select监视的文件==>时间复杂度O(n)描述符分为3类,用三个位图来表示fdset:writefds(可写)、readfds(可读)、exceptfds(异常)

调用流程:

调用select函数会阻塞,向内核拷贝整个fd(文件描述符集)(每次调用select都会完整的拷贝一份fd),通知内核监视fd,内核会轮询这些fd,直到有描述符就绪,(有数据可读、可写或有except),或者等待超时(若立即返回则设为null)。

当select返回后,内核会把整个fd拷贝给用户态程序,用户态程序从select那里仅仅知道有IO事件发生了,但并不知道是哪几个流,只能轮询所有流,对它们进行操作。

优点:

select目前几乎支持所有平台(良好的跨平台支持)。

缺点:

1.单进程能够监视的文件描述符数量存在最大限制

linux一般为1024(可修改宏定义或重新编译内核提升但会造成效率降低)

2.当某个连接有数据后,内核会通知用户有数据了,但不告诉是哪个,用户只能通过轮询一个个检查。

3.只支持水平触发

4. 调用slect需要把fd集合从用户态拷贝到内核态,来告知内核需要监视哪些文件描述符,同时每次调用select都需要在内核遍历传递进来的所有fd(fd很多时,开销会很大),poll同样存在这个问题。

poll(基本与select一致)==>时间复杂度O(n)

poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的。

和select一样,poll返回后,需要轮询pollfd来获取就绪的文件描述符

因为同时连接的大量客户端在同一时刻只有很少处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。

poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。

epoll==>时间复杂度O(1)

epoll是在linux2.6内核中提出的,是linux内核为处理大量文件描述符而做了改进的poll,能显著提高程序在大量并发连接中只有少量活跃的情况下的cpu的利用率。

epoll可以理解为event poll(事件池),不同于select和poll,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))。

epoll对比select和poll的优点:

1、没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
2、效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;
即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。

3、 epoll通过内核和用户空间共享一块内存来实现的消息的传递,用户空间和内核空间的消息传递,只需要拷贝一次fd。而不是像select和poll每次调用都需要传递全部fd。

4. 同时支持水平触发和边缘触发。

epoll的原理

将用户要监视的文件描述符的事件存放到内核的一个事件表中,这个表共享内存,这样在用户空间和内核空间的copy只需一次。

把原先的select/poll调用分成了3个函数:

1)调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源,epoll使用一个文件描述符管理多个文件描述符)。

当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体其中的两个成员:1. 红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件 2. 双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件。

每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。

2021-09-02_第21张图片

2)调用epoll_ctl向epoll对象中添加时间表中的事件。

epoll_ctl()系统调用。通过此调用向epoll对象中添加、删除、修改要监听的事件,返回0标识成功,返回-1表示失败。对于每一个事件,都会建立一个epitem结构体。

这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。

而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中
 

在epoll中,对于每一个事件,都会建立一个epitem结构体:

struct epitem{
    struct rb_node  rbn;//红黑树节点
    struct list_head    rdllink;//双向链表节点
    struct epoll_filefd  ffd;  //事件句柄信息
    struct eventpoll *ep;    //指向其所属的eventpoll对象
    struct epoll_event event; //期待发生的事件类型
}

3)调用epoll_wait检查是否有事件就绪(即双链表中是否有元素)

当调用epoll_wait检查是否有事件就绪时,不需要像select或者poll那样遍历整个fd,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把就绪的事件复制到用户态,同时将事件数量返回给用户。

epoll tips:

1.epoll会在被内核初始化的时候(操作系统启动时),开辟出自己的内核高速cache区(该catch区用来存放我们想监控的socket所组成的红黑树eventpoll)。

支持快速的查找、插入、删除

2.调用epoll_create时

(1)在内核cache里建立红黑树用于存储以后epoll_ctl传来的socket

(2)建立双向链表epitem用于存储准备就绪的事件

3.当我们执行epoll_ctl时,

(1)如果增加socket,先检测红黑树中是否存在,存在则立即返回,不存在则把要监听的socket放到epoll文件系统里file对象对应的红黑树eventpoll上。

(2)给内核中断处理程序注册一个回调函数(告诉内核,若该句柄的中断到了,就把其放到epitem里),可见epoll的基础就是回调。

4.当我们调用epoll_wait(告诉)时,不需要传递socket句柄给内核

因为内核已经存有要监控的句柄,此时仅仅观察epitem链表里有没有数据即可,有数据就返回,没有数据就sleep。

select、poll、epoll 区别总结:

1、支持一个进程所能打开的最大连接数

select:单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是3232,同理64位机器上FD_SETSIZE为3264),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试。

poll:poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的。

epoll:虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接。

2、FD剧增后带来的IO效率问题

select:因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。

poll:同上

epoll:因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。

3、 消息传递方式

select:内核需要将消息传递到用户空间,都需要内核拷贝动作

poll:同上

epoll:epoll通过内核和用户空间共享一块内存来实现的。

epoll IO多路复用模型实现机制举例

设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。

而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。

如何实现这样的高并发?

select/poll

在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去轮询这些套接字上是否有事件发生。

轮询完后,再将整个句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。

epoll

epoll的设计和实现与select完全不同。

epoll通过在Linux内核中申请一个简易的文件系统。把原先的select/poll调用分成了3个部分:

1)调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)

2)调用epoll_ctl向epoll对象中添加这100万个连接的套接字

3)调用epoll_wait收集发生的事件的连接

如此一来,要实现上面说是的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除连接。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有一股脑的向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接。
 

水平触发

将就绪的文件描述符告诉进程后

如果进程没有对其进行IO操作

那么下次调用epoll时将再次报告这些文件描述符

边缘触发

只告诉哪些文件描述符刚刚变为就绪状态

它只说一遍,如果我们没有采取行动,它将不会再次告知

理论上边缘触发性能更高,但代码实现相当复杂

29.channel原理

一个channel里的数据只能被一个goroutine读取到,底层的原理如下:

 type hchan struct {
       qcount   uint           // total data in the queue 当前队列里还剩余元素个数
       dataqsiz uint           // size of the circular queue 环形队列长度,即缓冲区的大小,即make(chan T,N) 中的N
       buf      unsafe.Pointer // points to an array of dataqsiz elements 环形队列指针
       elemsize uint16 //每个元素的大小
       closed   uint32 //标识当前通道是否处于关闭状态,创建通道后,该字段设置0,即打开通道;通道调用close将其设置为1,通道关闭
       elemtype *_type // element type 元素类型,用于数据传递过程中的赋值
       sendx    uint   // send index 环形缓冲区的状态字段,它只是缓冲区的当前索引-支持数组,它可以从中发送数据
       recvx    uint   // receive index 环形缓冲区的状态字段,它只是缓冲区当前索引-支持数组,它可以从中接受数据
       recvq    waitq  // list of recv waiters 等待读消息的goroutine队列
       sendq    waitq  // list of send waiters 等待写消息的goroutine队列
    
       // lock protects all fields in hchan, as well as several
       // fields in sudogs blocked on this channel.
       //
       // Do not change another G's status while holding this lock
       // (in particular, do not ready a G), as this can deadlock
       // with stack shrinking.
       lock mutex //互斥锁,为每个读写操作锁定通道,因为发送和接受必须是互斥操作
  }
  
  // sudog 代表goroutine
   type waitq struct {
        first *sudog
        last  *sudog
  }

channel发送原理:

当有一个goroutine要往channel写入数据时:

  • 锁定整个通道结构(这里加的是互斥锁,为每个读写操作锁定通道,因为发送和接受必须是互斥操作
  • channel从recvq队列中查看是否有goroutine,如果有则将元素直接写入recevq的goroutine中。
  • 如果recvq为Empty,则确定缓冲区是否可用,如果可用那么从当前goroutine复制数据到缓冲区中。
  • 如果缓冲区已经满了,则要写入的元素将保存在当前执行的goroutine结构中,并且将当前goroutine放置在sendq中,然后将队列从运行时挂起。
  • 写入完成释放锁

channel读取原理:

当有一个goroutine要从channel读取数据时:

  • 先获取channel全局锁,(这里加的是互斥锁,为每个读写操作锁定通道,因为发送和接受必须是互斥操作
  • 尝试sendq等待队列中获取等待的goroutine
  • 如果有等待的goroutine,没有缓冲区,取出goroutine并读取数据,然后唤醒这个goroutine,结束读取释放锁
  • 如果有等待goroutine,且有缓冲区(缓冲区满了),从缓冲区队列首取数据,再从sendq取出一个goroutine,将goroutine中的数据存放到buf队列尾,结束读取释放锁。
  • 如果没有等待的goroutine,且缓冲区有数据,直接读取缓冲区数据,结束释放锁。
  • 如果没有等待的goroutine,且没有缓冲区或者缓冲区为空,将当前goroutine加入到recvq队列,进入睡眠,等待被写入goroutine唤醒,结束读取释放锁。

30.常用的linux命令

查看进程:

ps -ef :列出所有进程,并显示环境变量,而且显示全格式。

ps -a : 只列出所有进程,并不显示环境变量。

杀掉进程:

kill pid

-9 表示强迫进程立即停止

查看端口:

netstat 

查找文件:

grep是用来在文件内部查找文字内容的,,而find是用来查找文件的。

find :find 某个范围下 -name 模糊匹配

-type f :类型是f

eg:find .(当前目录及其子目录下) -name “*.c”

grep:grep -参数  要匹配的字符串 查找的范围(模糊查询:前缀是某某,后缀是某某等等)

more和less

将文件从第一行开始,根据输出窗口的大小,适当的输出文件内容。当一页无法全部输出时,可以可以翻页,less对比more,支持向前翻页。

查看日志

tail -f 

top 命令

显示当前系统正在执行的进程的相关信息,包括进程 ID、内存占用率、CPU 占用率等

top 命令来查看 CPU 使用状况,包括用户空间和内核空间

 load average后面的三个数分别是1分钟、5分钟、15分钟的负载情况。


31.go 正常模式和饥饿模式

在Go一共可以分为两种抢锁的模式,一种是正常模式,另外一种是饥饿模式。

正常模式(非公平锁)
      在刚开始的时候,是处于正常模式(Barging),也就是,当一个G1持有着一个锁的时候,G2会自旋的去尝试获取这个锁;
当自旋超过4次还没有能获取到锁的时候,这个G2就会被加入到获取锁的等待队列里面,并阻塞等待唤醒。
当G1释放锁时,等待队列里的G2会被唤醒,与其他协程竞争锁,这样,处于自旋状态下的协程更容易获得锁;


饥饿模式是 1.9 版本中引入的优化,目的是保证互斥锁的公平性,防止协程饿死。默认情况下,Mutex 的模式为正常模式。


饥饿模式(公平锁)
      为了解决了等待 goroutine 队列的长尾问题,在饥饿模式下,直接由 unlock 把锁交给等待队列中排在第一位的 goroutine (队 头),
同时,饥饿模式下,新进来的 goroutine 不会参与抢锁也不会进入自旋状 态,会直接进入等待队列的尾部。这样很好的解决了老的 goroutine一直抢不 到锁的场景。
饥饿模式的触发条件:当一个 goroutine 等待锁时间超过 1 毫秒时,或者当前 队列只剩下一个 goroutine 的时候,Mutex 切换到饥饿模式。


 

你可能感兴趣的:(面筋,golang)