Golang面试问题汇总

Golang面试问题汇总

部分图片转载失败https://github.com/KeKe-Li/data-structures-questions/blob/master/src/chapter05/golang.01.md

通常我们去面试肯定会有些不错的Golang的面试题目的,所以总结下,让其他Golang开发者也可以查看到,同时也用来检测自己的能力和提醒自己的不足之处,欢迎大家补充和提交新的面试题目.

Golang面试问题汇总:

1. Golang中除了加Mutex锁以外还有哪些方式安全读写共享变量

Golang中Goroutine 可以通过 Channel 进行安全读写共享变量。

2. 无缓冲Chan的发送和接收是否同步

ch := make(chan int)    无缓冲的channel由于没有缓冲发送和接收需要同步.
ch := make(chan int, 2) 有缓冲channel不要求发送和接收操作同步. 
  • channel无缓冲时,发送阻塞直到数据被接收,接收阻塞直到读到数据。
  • channel有缓冲时,当缓冲满时发送阻塞,当缓冲空时接收阻塞。

3. Golang语言的并发机制以及它所使用的CSP并发模型.

CSP模型是上个世纪七十年代提出的,不同于传统的多线程通过共享内存来通信,CSP讲究的是“以通信的方式来共享内存”。用于描述两个独立的并发实体通过共享的通讯 channel(管道)进行通信的并发模型。 CSP中channel是第一类对象,它不关注发送消息的实体,而关注与发送消息时使用的channel。

Golang中channel 是被单独创建并且可以在进程之间传递,它的通信模式类似于 boss-worker 模式的,一个实体通过将消息发送到channel 中,然后又监听这个 channel 的实体处理,两个实体之间是匿名的,这个就实现实体中间的解耦,其中 channel 是同步的一个消息被发送到 channel 中,最终是一定要被另外的实体消费掉的,在实现原理上其实类似一个阻塞的消息队列。

Goroutine 是Golang实际并发执行的实体,它底层是使用协程(coroutine)实现并发,coroutine是一种运行在用户态的用户线程,类似于 greenthread,go底层选择使用coroutine的出发点是因为,它具有以下特点:

  • 用户空间 避免了内核态和用户态的切换导致的成本。
  • 可以由语言和框架层进行调度。
  • 更小的栈空间允许创建大量的实例。

Golang中的Goroutine的特性:

Golang内部有三个对象: P对象(processor) 代表上下文(或者可以认为是cpu),M(work thread)代表工作线程,G对象(goroutine).

正常情况下一个cpu对象启一个工作线程对象,线程去检查并执行goroutine对象。碰到goroutine对象阻塞的时候,会启动一个新的工作线程,以充分利用cpu资源。 所有有时候线程对象会比处理器对象多很多.

我们用如下图分别表示P、M、G:

 

G(Goroutine) :我们所说的协程,为用户级的轻量级线程,每个Goroutine对象中的sched保存着其上下文信息.

M(Machine) :对内核级线程的封装,数量对应真实的CPU数(真正干活的对象).

P(Processor) :即为G和M的调度对象,用来调度G和M之间的关联关系,其数量可通过GOMAXPROCS()来设置,默认为核心数.

在单核情况下,所有Goroutine运行在同一个线程(M0)中,每一个线程维护一个上下文(P),任何时刻,一个上下文中只有一个Goroutine,其他Goroutine在runqueue中等待。

一个Goroutine运行完自己的时间片后,让出上下文,自己回到runqueue中(如下图所示)。

当正在运行的G0阻塞的时候(可以需要IO),会再创建一个线程(M1),P转到新的线程中去运行。

 

当M0返回时,它会尝试从其他线程中“偷”一个上下文过来,如果没有偷到,会把Goroutine放到Global runqueue中去,然后把自己放入线程缓存中。 上下文会定时检查Global runqueue。

Golang是为并发而生的语言,Go语言是为数不多的在语言层面实现并发的语言;也正是Go语言的并发特性,吸引了全球无数的开发者。

Golang的CSP并发模型,是通过Goroutine和Channel来实现的。

Goroutine 是Go语言中并发的执行单位。有点抽象,其实就是和传统概念上的”线程“类似,可以理解为”线程“。 Channel是Go语言中各个并发结构体(Goroutine)之前的通信机制。通常Channel,是各个Goroutine之间通信的”管道“,有点类似于Linux中的管道。

通信机制channel也很方便,传数据用channel <- data,取数据用<-channel。

在通信过程中,传数据channel <- data和取数据<-channel必然会成对出现,因为这边传,那边取,两个goroutine之间才会实现通信。

而且不管是传还是取,肯定阻塞,直到另外的goroutine传或者取为止。

4. Golang中常用的并发模型?

Golang 中常用的并发模型有三种:

  • 通过channel通知实现并发控制

无缓冲的通道指的是通道的大小为0,也就是说,这种类型的通道在接收前没有能力保存任何值,它要求发送 goroutine 和接收 goroutine 同时准备好,才可以完成发送和接收操作。

从上面无缓冲的通道定义来看,发送 goroutine 和接收 gouroutine 必须是同步的,同时准备后,如果没有同时准备好的话,先执行的操作就会阻塞等待,直到另一个相对应的操作准备好为止。这种无缓冲的通道我们也称之为同步通道。

func main() {
    ch := make(chan struct{})
    go func() {
        fmt.Println("start working")
        time.Sleep(time.Second * 1)
        ch <- struct{}{}
    }()

    <-ch

    fmt.Println("finished")
}

当主 goroutine 运行到 <-ch 接受 channel 的值的时候,如果该 channel 中没有数据,就会一直阻塞等待,直到有值。 这样就可以简单实现并发控制

  • 通过sync包中的WaitGroup实现并发控制

Goroutine是异步执行的,有的时候为了防止在结束main函数的时候结束掉Goroutine,所以需要同步等待,这个时候就需要用 WaitGroup了,在 sync 包中,提供了 WaitGroup ,它会等待它收集的所有 goroutine 任务全部完成。在WaitGroup里主要有三个方法:

  • Add, 可以添加或减少 goroutine的数量.
  • Done, 相当于Add(-1).
  • Wait, 执行后会堵塞主线程,直到WaitGroup 里的值减至0.

在主 goroutine 中 Add(delta int) 索要等待goroutine 的数量。 在每一个 goroutine 完成后 Done() 表示这一个goroutine 已经完成,当所有的 goroutine 都完成后,在主 goroutine 中 WaitGroup 返回返回。

func main(){
    var wg sync.WaitGroup
    var urls = []string{
        "http://www.golang.org/",
        "http://www.google.com/",
    }
    for _, url := range urls {
        wg.Add(1)
        go func(url string) {
            defer wg.Done()
            http.Get(url)
        }(url)
    }
    wg.Wait()
}

在Golang官网中对于WaitGroup介绍是A WaitGroup must not be copied after first use,在 WaitGroup 第一次使用后,不能被拷贝

应用示例:

func main(){
 wg := sync.WaitGroup{}
    for i := 0; i < 5; i++ {
        wg.Add(1)
        go func(wg sync.WaitGroup, i int) {
            fmt.Printf("i:%d", i)
            wg.Done()
        }(wg, i)
    }
    wg.Wait()
    fmt.Println("exit")
}

运行:

i:1i:3i:2i:0i:4fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_Semacquire(0xc000094018)
        /home/keke/soft/go/src/runtime/sema.go:56 +0x39
sync.(*WaitGroup).Wait(0xc000094010)
        /home/keke/soft/go/src/sync/waitgroup.go:130 +0x64
main.main()
        /home/keke/go/Test/wait.go:17 +0xab
exit status 2

它提示所有的 goroutine 都已经睡眠了,出现了死锁。这是因为 wg 给拷贝传递到了 goroutine 中,导致只有 Add 操作,其实 Done操作是在 wg 的副本执行的。

因此 Wait 就会死锁。

这个第一个修改方式:将匿名函数中 wg 的传入类型改为 *sync.WaitGrou,这样就能引用到正确的WaitGroup了。

这个第二个修改方式:将匿名函数中的 wg 的传入参数去掉,因为Go支持闭包类型,在匿名函数中可以直接使用外面的 wg 变量

  • 在Go 1.7 以后引进的强大的Context上下文,实现并发控制

通常,在一些简单场景下使用 channel 和 WaitGroup 已经足够了,但是当面临一些复杂多变的网络并发场景下 channel 和 WaitGroup 显得有些力不从心了。 比如一个网络请求 Request,每个 Request 都需要开启一个 goroutine 做一些事情,这些 goroutine 又可能会开启其他的 goroutine,比如数据库和RPC服务。

所以我们需要一种可以跟踪 goroutine 的方案,才可以达到控制他们的目的,这就是Go语言为我们提供的 Context,称之为上下文非常贴切,它就是goroutine 的上下文。

它是包括一个程序的运行环境、现场和快照等。每个程序要运行时,都需要知道当前程序的运行状态,通常Go 将这些封装在一个 Context 里,再将它传给要执行的 goroutine 。

context 包主要是用来处理多个 goroutine 之间共享数据,及多个 goroutine 的管理。

context 包的核心是 struct Context,接口声明如下:

// A Context carries a deadline, cancelation signal, and request-scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
    // Done returns a channel that is closed when this `Context` is canceled
    // or times out.
    // Done() 返回一个只能接受数据的channel类型,当该context关闭或者超时时间到了的时候,该channel就会有一个取消信号
    Done() <-chan struct{}


    // Err indicates why this Context was canceled, after the Done channel
    // is closed.
    // Err() 在Done() 之后,返回context 取消的原因。
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    // Deadline() 设置该context cancel的时间点
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    // Value() 方法允许 Context 对象携带request作用域的数据,该数据必须是线程安全的。
    Value(key interface{}) interface{}
}

Context 对象是线程安全的,你可以把一个 Context 对象传递给任意个数的 gorotuine,对它执行 取消 操作时,所有 goroutine 都会接收到取消信号。

一个 Context 不能拥有 Cancel 方法,同时我们也只能 Done channel 接收数据。其中的原因是一致的:接收取消信号的函数和发送信号的函数通常不是一个。

典型的场景是:父操作为子操作操作启动 goroutine,子操作也就不能取消父操作。

5.JSON 标准库对nil slice和 空 slice 的处理是一致的吗? 

首先JSON 标准库对 nil slice 和 空 slice 的处理是不一致.

通常错误的用法,会报数组越界的错误,因为只是声明了slice,却没有给实例化的对象。

var slice []int
slice[1] = 0

此时slice的值是nil,这种情况可以用于需要返回slice的函数,当函数出现异常的时候,保证函数依然会有nil的返回值。

empty slice 是指slice不为nil,但是slice没有值,slice的底层的空间是空的,此时的定义如下:

slice := make([]int,0)
slice := []int{}

当我们查询或者处理一个空的列表的时候,这非常有用,它会告诉我们返回的是一个列表,但是列表内没有任何值。

总之,nil slice 和 empty slice是不同的东西,需要我们加以区分的.

6. 协程,线程,进程的区别。

  • 进程

进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。

  • 线程

线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源。线程间通信主要通过共享内存,上下文切换很快,资源开销较少,但相比进程不够稳定容易丢失数据。

  • 协程

协程是一种用户态的轻量级线程,协程的调度完全由用户控制。协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量,所以上下文的切换非常快。

7. 互斥锁,读写锁,死锁问题是怎么解决。

  • 互斥锁

互斥锁就是互斥变量mutex,用来锁住临界区的.

条件锁就是条件变量,当进程的某些资源要求不满足时就进入休眠,也就是锁住了。当资源被分配到了,条件锁打开,进程继续运行;读写锁,也类似,用于缓冲区等临界资源能互斥访问的。

  • 读写锁

通常有些公共数据修改的机会很少,但其读的机会很多。并且在读的过程中会伴随着查找,给这种代码加锁会降低我们的程序效率。读写锁可以解决这个问题。

 

注意:写独占,读共享,写锁优先级高

  • 死锁

一般情况下,如果同一个线程先后两次调用lock,在第二次调用时,由于锁已经被占用,该线程会挂起等待别的线程释放锁,然而锁正是被自己占用着的,该线程又被挂起而没有机会释放锁,因此就永远处于挂起等待状态了,这叫做死锁(Deadlock)。 另外一种情况是:若线程A获得了锁1,线程B获得了锁2,这时线程A调用lock试图获得锁2,结果是需要挂起等待线程B释放锁2,而这时线程B也调用lock试图获得锁1,结果是需要挂起等待线程A释放锁1,于是线程A和B都永远处于挂起状态了。

死锁产生的四个必要条件:

  1. 互斥条件:一个资源每次只能被一个进程使用
  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:进程已获得的资源,在未使用完之前,不能强行剥夺。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。

a. 预防死锁

可以把资源一次性分配:(破坏请求和保持条件)

然后剥夺资源:即当某进程新的资源未满足时,释放已占有的资源(破坏不可剥夺条件)

资源有序分配法:系统给每类资源赋予一个编号,每一个进程按编号递增的顺序请求资源,释放则相反(破坏环路等待条件)

b. 避免死锁

预防死锁的几种策略,会严重地损害系统性能。因此在避免死锁时,要施加较弱的限制,从而获得 较满意的系统性能。由于在避免死锁的策略中,允许进程动态地申请资源。因而,系统在进行资源分配之前预先计算资源分配的安全性。若此次分配不会导致系统进入不安全状态,则将资源分配给进程;否则,进程等待。其中最具有代表性的避免死锁算法是银行家算法。

c. 检测死锁

首先为每个进程和每个资源指定一个唯一的号码,然后建立资源分配表和进程等待表.

d. 解除死锁

当发现有进程死锁后,便应立即把它从死锁状态中解脱出来,常采用的方法有.

e. 剥夺资源

从其它进程剥夺足够数量的资源给死锁进程,以解除死锁状态.

f. 撤消进程

可以直接撤消死锁进程或撤消代价最小的进程,直至有足够的资源可用,死锁状态.消除为止.所谓代价是指优先级、运行代价、进程的重要性和价值等。

8. Golang的内存模型,为什么小对象多了会造成gc压力。

通常小对象过多会导致GC三色法消耗过多的GPU。优化思路是,减少对象分配.

9. Data Race问题怎么解决?能不能不加锁解决这个问题?

同步访问共享数据是处理数据竞争的一种有效的方法.golang在1.1之后引入了竞争检测机制,可以使用 go run -race 或者 go build -race来进行静态检测。 其在内部的实现是,开启多个协程执行同一个命令, 并且记录下每个变量的状态.

竞争检测器基于C/C++的ThreadSanitizer 运行时库,该库在Google内部代码基地和Chromium找到许多错误。这个技术在2012年九月集成到Go中,从那时开始,它已经在标准库中检测到42个竞争条件。现在,它已经是我们持续构建过程的一部分,当竞争条件出现时,它会继续捕捉到这些错误。

竞争检测器已经完全集成到Go工具链中,仅仅添加-race标志到命令行就使用了检测器。

$ go test -race mypkg    // 测试包
$ go run -race mysrc.go  // 编译和运行程序
$ go build -race mycmd   // 构建程序
$ go install -race mypkg // 安装程序

要想解决数据竞争的问题可以使用互斥锁sync.Mutex,解决数据竞争(Data race),也可以使用管道解决,使用管道的效率要比互斥锁高.

10. 什么是channel,为什么它可以做到线程安全?

Channel是Go中的一个核心类型,可以把它看成一个管道,通过它并发核心单元就可以发送或者接收数据进行通讯(communication),Channel也可以理解是一个先进先出的队列,通过管道进行通信。

Golang的Channel,发送一个数据到Channel 和 从Channel接收一个数据 都是 原子性的。而且Go的设计思想就是:不要通过共享内存来通信,而是通过通信来共享内存,前者就是传统的加锁,后者就是Channel。也就是说,设计Channel的主要目的就是在多任务间传递数据的,这当然是安全的。

11. Epoll原理.

开发高性能网络程序时,windows开发者们言必称Iocp,linux开发者们则言必称Epoll。大家都明白Epoll是一种IO多路复用技术,可以非常高效的处理数以百万计的Socket句柄,比起以前的Select和Poll效率提高了很多。

先简单了解下如何使用C库封装的3个epoll系统调用。

int epoll_create(int size);  
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);  
int epoll_wait(int epfd, struct epoll_event *events,int maxevents, int timeout);  

使用起来很清晰,首先要调用epoll_create建立一个epoll对象。参数size是内核保证能够正确处理的最大句柄数,多于这个最大数时内核可不保证效果。 epoll_ctl可以操作上面建立的epoll,例如,将刚建立的socket加入到epoll中让其监控,或者把 epoll正在监控的某个socket句柄移出epoll,不再监控它等等。

epoll_wait在调用时,在给定的timeout时间内,当在监控的所有句柄中有事件发生时,就返回用户态的进程。

从调用方式就可以看到epoll相比select/poll的优越之处是,因为后者每次调用时都要传递你所要监控的所有socket给select/poll系统调用,这意味着需要将用户态的socket列表copy到内核态,如果以万计的句柄会导致每次都要copy几十几百KB的内存到内核态,非常低效。而我们调用epoll_wait时就相当于以往调用select/poll,但是这时却不用传递socket句柄给内核,因为内核已经在epoll_ctl中拿到了要监控的句柄列表。

所以,实际上在你调用epoll_create后,内核就已经在内核态开始准备帮你存储要监控的句柄了,每次调用epoll_ctl只是在往内核的数据结构里塞入新的socket句柄。

在内核里,一切皆文件。所以,epoll向内核注册了一个文件系统,用于存储上述的被监控socket。当你调用epoll_create时,就会在这个虚拟的epoll文件系统里创建一个file结点。当然这个file不是普通文件,它只服务于epoll。

epoll在被内核初始化时(操作系统启动),同时会开辟出epoll自己的内核高速cache区,用于安置每一个我们想监控的socket,这些socket会以红黑树的形式保存在内核cache里,以支持快速的查找、插入、删除。这个内核高速cache区,就是建立连续的物理内存页,然后在之上建立slab层,通常来讲,就是物理上分配好你想要的size的内存对象,每次使用时都是使用空闲的已分配好的对象。

static int __init eventpoll_init(void)  {  
    ... ...  
  
    /* Allocates slab cache used to allocate "struct epitem" items */  
    epi_cache = kmem_cache_create("eventpoll_epi", sizeof(struct epitem),  
            0, SLAB_HWCACHE_ALIGN|EPI_SLAB_DEBUG|SLAB_PANIC,  
            NULL, NULL);  
  
    /* Allocates slab cache used to allocate "struct eppoll_entry" */  
    pwq_cache = kmem_cache_create("eventpoll_pwq",  
            sizeof(struct eppoll_entry), 0,  
            EPI_SLAB_DEBUG|SLAB_PANIC, NULL, NULL);  
 ... ...  
 }

epoll的高效就在于,当我们调用epoll_ctl往里塞入百万个句柄时,epoll_wait仍然可以飞快的返回,并有效的将发生事件的句柄给我们用户。这是由于我们在调用epoll_create时,内核除了帮我们在epoll文件系统里建了个file结点,在内核cache里建了个红黑树用于存储以后epoll_ctl传来的socket外,还会再建立一个list链表,用于存储准备就绪的事件,当epoll_wait调用时,仅仅观察这个list链表里有没有数据即可。有数据就返回,没有数据就sleep,等到timeout时间到后即使链表没数据也返回。所以,epoll_wait非常高效。

而且,通常情况下即使我们要监控百万计的句柄,大多一次也只返回很少量的准备就绪句柄而已,所以,epoll_wait仅需要从内核态copy少量的句柄到用户态而已,因此就会非常的高效!

然而,这个准备就绪list链表是怎么维护的呢?当我们执行epoll_ctl时,除了把socket放到epoll文件系统里file对象对应的红黑树上之外,还会给内核中断处理程序注册一个回调函数,告诉内核,如果这个句柄的中断到了,就把它放到准备就绪list链表里。所以,当一个socket上有数据到了,内核在把网卡上的数据copy到内核中后就来把socket插入到准备就绪链表里了。

如此,一个红黑树,一张准备就绪句柄链表,少量的内核cache,就帮我们解决了大并发下的socket处理问题。执行epoll_create时,创建了红黑树和就绪链表,执行epoll_ctl时,如果增加socket句柄,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上,然后向内核注册回调函数,用于当中断事件来临时向准备就绪链表中插入数据。执行epoll_wait时立刻返回准备就绪链表里的数据即可。

最后看看epoll独有的两种模式LT和ET。无论是LT和ET模式,都适用于以上所说的流程。区别是,LT模式下,只要一个句柄上的事件一次没有处理完,会在以后调用epoll_wait时每次返回这个句柄,而ET模式仅在第一次返回。

当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪list链表,这时我们调用epoll_wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后,epoll_wait需要做的事情,就是检查这些socket,如果不是ET模式(就是LT模式的句柄了),并且这些socket上确实有未处理的事件时,又把该句柄放回到刚刚清空的准备就绪链表了。所以,非ET的句柄,只要它上面还有事件,epoll_wait每次都会返回。而ET模式的句柄,除非有新中断到,即使socket上的事件没有处理完,也是不会每次从epoll_wait返回的。

因此epoll比select的提高实际上是一个用空间换时间思想的具体应用.对比阻塞IO的处理模型, 可以看到采用了多路复用IO之后, 程序可以自由的进行自己除了IO操作之外的工作, 只有到IO状态发生变化的时候由多路复用IO进行通知, 然后再采取相应的操作, 而不用一直阻塞等待IO状态发生变化,提高效率.

12. Golang GC 时会发生什么?

首先我们先来了解下垃圾回收.什么是垃圾回收?

内存管理是程序员开发应用的一大难题。传统的系统级编程语言(主要指C/C++)中,程序开发者必须对内存小心的进行管理操作,控制内存的申请及释放。因为稍有不慎,就可能产生内存泄露问题,这种问题不易发现并且难以定位,一直成为困扰程序开发者的噩梦。如何解决这个头疼的问题呢?

过去一般采用两种办法:

  • 内存泄露检测工具。这种工具的原理一般是静态代码扫描,通过扫描程序检测可能出现内存泄露的代码段。然而检测工具难免有疏漏和不足,只能起到辅助作用。

  • 智能指针。这是 c++ 中引入的自动内存管理方法,通过拥有自动内存管理功能的指针对象来引用对象,是程序员不用太关注内存的释放,而达到内存自动释放的目的。这种方法是采用最广泛的做法,但是对程序开发者有一定的学习成本(并非语言层面的原生支持),而且一旦有忘记使用的场景依然无法避免内存泄露。

为了解决这个问题,后来开发出来的几乎所有新语言(java,python,php等等)都引入了语言层面的自动内存管理 – 也就是语言的使用者只用关注内存的申请而不必关心内存的释放,内存释放由虚拟机(virtual machine)或运行时(runtime)来自动进行管理。而这种对不再使用的内存资源进行自动回收的行为就被称为垃圾回收。

常用的垃圾回收的方法:

  • 引用计数(reference counting)

这是最简单的一种垃圾回收算法,和之前提到的智能指针异曲同工。对每个对象维护一个引用计数,当引用该对象的对象被销毁或更新时被引用对象的引用计数自动减一,当被引用对象被创建或被赋值给其他对象时引用计数自动加一。当引用计数为0时则立即回收对象。

这种方法的优点是实现简单,并且内存的回收很及时。这种算法在内存比较紧张和实时性比较高的系统中使用的比较广泛,如ios cocoa框架,php,python等。

但是简单引用计数算法也有明显的缺点:

  1. 频繁更新引用计数降低了性能。

一种简单的解决方法就是编译器将相邻的引用计数更新操作合并到一次更新;还有一种方法是针对频繁发生的临时变量引用不进行计数,而是在引用达到0时通过扫描堆栈确认是否还有临时对象引用而决定是否释放。等等还有很多其他方法,具体可以参考这里。

  1. 循环引用。

当对象间发生循环引用时引用链中的对象都无法得到释放。最明显的解决办法是避免产生循环引用,如cocoa引入了strong指针和weak指针两种指针类型。或者系统检测循环引用并主动打破循环链。当然这也增加了垃圾回收的复杂度。

  • 标记-清除(mark and sweep)

标记-清除(mark and sweep)分为两步,标记从根变量开始迭代得遍历所有被引用的对象,对能够通过应用遍历访问到的对象都进行标记为“被引用”;标记完成后进行清除操作,对没有标记过的内存进行回收(回收同时可能伴有碎片整理操作)。这种方法解决了引用计数的不足,但是也有比较明显的问题:每次启动垃圾回收都会暂停当前所有的正常代码执行,回收是系统响应能力大大降低!当然后续也出现了很多mark&sweep算法的变种(如三色标记法)优化了这个问题。

  • 分代搜集(generation)

java的jvm 就使用的分代回收的思路。在面向对象编程语言中,绝大多数对象的生命周期都非常短。分代收集的基本思想是,将堆划分为两个或多个称为代(generation)的空间。新创建的对象存放在称为新生代(young generation)中(一般来说,新生代的大小会比 老年代小很多),随着垃圾回收的重复执行,生命周期较长的对象会被提升(promotion)到老年代中(这里用到了一个分类的思路,这个是也是科学思考的一个基本思路)。

因此,新生代垃圾回收和老年代垃圾回收两种不同的垃圾回收方式应运而生,分别用于对各自空间中的对象执行垃圾回收。新生代垃圾回收的速度非常快,比老年代快几个数量级,即使新生代垃圾回收的频率更高,执行效率也仍然比老年代垃圾回收强,这是因为大多数对象的生命周期都很短,根本无需提升到老年代。

Golang GC 时会发生什么?

Golang 1.5后,采取的是“非分代的、非移动的、并发的、三色的”标记清除垃圾回收算法。

golang 中的 gc 基本上是标记清除的过程:

 

gc的过程一共分为四个阶段:

  1. 栈扫描(开始时STW)
  2. 第一次标记(并发)
  3. 第二次标记(STW)
  4. 清除(并发)

整个进程空间里申请每个对象占据的内存可以视为一个图,初始状态下每个内存对象都是白色标记。

  1. 先STW,做一些准备工作,比如 enable write barrier。然后取消STW,将扫描任务作为多个并发的goroutine立即入队给调度器,进而被CPU处理
  2. 第一轮先扫描root对象,包括全局指针和 goroutine 栈上的指针,标记为灰色放入队列
  3. 第二轮将第一步队列中的对象引用的对象置为灰色加入队列,一个对象引用的所有对象都置灰并加入队列后,这个对象才能置为黑色并从队列之中取出。循环往复,最后队列为空时,整个图剩下的白色内存空间即不可到达的对象,即没有被引用的对象;
  4. 第三轮再次STW,将第二轮过程中新增对象申请的内存进行标记(灰色),这里使用了write barrier(写屏障)去记录

Golang gc 优化的核心就是尽量使得 STW(Stop The World) 的时间越来越短。

详细的Golang的GC介绍可以参看Golang垃圾回收.

13. Golang 中 Goroutine 如何调度?

goroutine是Golang语言中最经典的设计,也是其魅力所在,goroutine的本质是协程,是实现并行计算的核心。 goroutine使用方式非常的简单,只需使用go关键字即可启动一个协程,并且它是处于异步方式运行,你不需要等它运行完成以后在执行以后的代码。

go func()//通过go关键字启动一个协程来运行函数

协程:

协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈。 因此,协程能保留上一次调用时的状态(即所有局部状态的一个特定组合),每次过程重入时,就相当于进入上一次调用的状态,换种说法:进入上一次离开时所处逻辑流的位置。

线程和进程的操作是由程序触发系统接口,最后的执行者是系统;协程的操作执行者则是用户自身程序,goroutine也是协程。

groutine能拥有强大的并发实现是通过GPM调度模型实现.

 

Go的调度器内部有四个重要的结构:M,P,S,Sched,如上图所示(Sched未给出).

  • M:M代表内核级线程,一个M就是一个线程,goroutine就是跑在M之上的;M是一个很大的结构,里面维护小对象内存cache(mcache)、当前执行的goroutine、随机数发生器等等非常多的信息
  • G:代表一个goroutine,它有自己的栈,instruction pointer和其他信息(正在等待的channel等等),用于调度。
  • P:P全称是Processor,处理器,它的主要用途就是用来执行goroutine的,所以它也维护了一个goroutine队列,里面存储了所有需要它来执行的goroutine
  • Sched:代表调度器,它维护有存储M和G的队列以及调度器的一些状态信息等。

调度实现:

 

从上图中可以看到,有2个物理线程M,每一个M都拥有一个处理器P,每一个也都有一个正在运行的goroutine。P的数量可以通过GOMAXPROCS()来设置,它其实也就代表了真正的并发度,即有多少个goroutine可以同时运行。

图中灰色的那些goroutine并没有运行,而是出于ready的就绪态,正在等待被调度。P维护着这个队列(称之为runqueue),Go语言里,启动一个goroutine很容易:go function 就行,所以每有一个go语句被执行,runqueue队列就在其末尾加入一个goroutine,在下一个调度点,就从runqueue中取出(如何决定取哪个goroutine?)一个goroutine执行。

当一个OS线程M0陷入阻塞时,P转而在运行M1,图中的M1可能是正被创建,或者从线程缓存中取出。

 

当MO返回时,它必须尝试取得一个P来运行goroutine,一般情况下,它会从其他的OS线程那里拿一个P过来, 如果没有拿到的话,它就把goroutine放在一个global runqueue里,然后自己睡眠(放入线程缓存里)。所有的P也会周期性的检查global runqueue并运行其中的goroutine,否则global runqueue上的goroutine永远无法执行。

另一种情况是P所分配的任务G很快就执行完了(分配不均),这就导致了这个处理器P处于空闲的状态,但是此时其他的P还有任务,此时如果global runqueue没有任务G了,那么这个P就会从其他的P里偷取一些G来执行。

 

通常来说,如果P从其他的P那里要拿任务的话,一般就拿run queue的一半,这就确保了每个OS线程都能充分的使用。

14. 并发编程概念是什么?

并行是指两个或者多个事件在同一时刻发生;并发是指两个或多个事件在同一时间间隔发生。

并行是在不同实体上的多个事件,并发是在同一实体上的多个事件。在一台处理器上“同时”处理多个任务,在多台处理器上同时处理多个任务。如hadoop分布式集群

并发偏重于多个任务交替执行,而多个任务之间有可能还是串行的。而并行是真正意义上的“同时执行”。

并发编程是指在一台处理器上“同时”处理多个任务。并发是在同一实体上的多个事件。多个事件在同一时间间隔发生。并发编程的目标是充分的利用处理器的每一个核,以达到最高的处理性能。

15. 负载均衡原理是什么?

负载均衡Load Balance)是高可用网络基础架构的关键组件,通常用于将工作负载分布到多个服务器来提高网站、应用、数据库或其他服务的性能和可靠性。负载均衡,其核心就是网络流量分发,分很多维度。

负载均衡(Load Balance)通常是分摊到多个操作单元上进行执行,例如Web服务器、FTP服务器、企业关键应用服务器和其它关键任务服务器等,从而共同完成工作任务。

负载均衡是建立在现有网络结构之上,它提供了一种廉价有效透明的方法扩展网络设备和服务器的带宽、增加吞吐量、加强网络数据处理能力、提高网络的灵活性和可用性。

下面通过一个例子详细介绍:

  • 没有负载均衡 web 架构

 

在这里用户是直连到 web 服务器,如果这个服务器宕机了,那么用户自然也就没办法访问了。 另外,如果同时有很多用户试图访问服务器,超过了其能处理的极限,就会出现加载速度缓慢或根本无法连接的情况。

而通过在后端引入一个负载均衡器和至少一个额外的 web 服务器,可以缓解这个故障。 通常情况下,所有的后端服务器会保证提供相同的内容,以便用户无论哪个服务器响应,都能收到一致的内容。

  • 有负载均衡 web 架构

 

用户访问负载均衡器,再由负载均衡器将请求转发给后端服务器。在这种情况下,单点故障现在转移到负载均衡器上了。这里又可以通过引入第二个负载均衡器来缓解。

那么负载均衡器的工作方式是什么样的呢,负载均衡器又可以处理什么样的请求?

负载均衡器的管理员能主要为下面四种主要类型的请求设置转发规则:

  • HTTP (七层)
  • HTTPS (七层)
  • TCP (四层)
  • UDP (四层)

负载均衡器如何选择要转发的后端服务器?

负载均衡器一般根据两个因素来决定要将请求转发到哪个服务器。首先,确保所选择的服务器能够对请求做出响应,然后根据预先配置的规则从健康服务器池(healthy pool)中进行选择。

因为,负载均衡器应当只选择能正常做出响应的后端服务器,因此就需要有一种判断后端服务器是否健康的方法。为了监视后台服务器的运行状况,运行状态检查服务会定期尝试使用转发规则定义的协议和端口去连接后端服务器。 如果,服务器无法通过健康检查,就会从池中剔除,保证流量不会被转发到该服务器,直到其再次通过健康检查为止。

负载均衡算法:

负载均衡算法决定了后端的哪些健康服务器会被选中。 其中常用的算法包括:

  • Round Robin(轮询):为第一个请求选择列表中的第一个服务器,然后按顺序向下移动列表直到结尾,然后循环。
  • Least Connections(最小连接):优先选择连接数最少的服务器,在普遍会话较长的情况下推荐使用。
  • Source:根据请求源的 IP 的散列(hash)来选择要转发的服务器。这种方式可以一定程度上保证特定用户能连接到相同的服务器。

如果你的应用需要处理状态而要求用户能连接到和之前相同的服务器。可以通过 Source 算法基于客户端的 IP 信息创建关联,或者使用粘性会话(sticky sessions)。

除此之外,想要解决负载均衡器的单点故障问题,可以将第二个负载均衡器连接到第一个上,从而形成一个集群。

16. LVS相关了解.

LVS是 Linux Virtual Server 的简称,也就是Linux虚拟服务器。这是一个由章文嵩博士发起的一个开源项目,它的官方网站是LinuxVirtualServer现在 LVS 已经是 Linux 内核标准的一部分。使用 LVS 可以达到的技术目标是:通过 LVS 达到的负载均衡技术和 Linux 操作系统实现一个高性能高可用的 Linux 服务器集群,它具有良好的可靠性、可扩展性和可操作性。 从而以低廉的成本实现最优的性能。LVS 是一个实现负载均衡集群的开源软件项目,LVS架构从逻辑上可分为调度层、Server集群层和共享存储。

LVS的基本工作原理:

 

  1. 当用户向负载均衡调度器(Director Server)发起请求,调度器将请求发往至内核空间
  2. PREROUTING链首先会接收到用户请求,判断目标IP确定是本机IP,将数据包发往INPUT链
  3. IPVS是工作在INPUT链上的,当用户请求到达INPUT时,IPVS会将用户请求和自己已定义好的集群服务进行比对,如果用户请求的就是定义的集群服务,那么此时IPVS会强行修改数据包里的目标IP地址及端口,并将新的数据包发往POSTROUTING链
  4. POSTROUTING链接收数据包后发现目标IP地址刚好是自己的后端服务器,那么此时通过选路,将数据包最终发送给后端的服务器

LVS的组成:

LVS 由2部分程序组成,包括 ipvs 和 ipvsadm

  1. ipvs(ip virtual server):一段代码工作在内核空间,叫ipvs,是真正生效实现调度的代码。
  2. ipvsadm:另外一段是工作在用户空间,叫ipvsadm,负责为ipvs内核框架编写规则,定义谁是集群服务,而谁是后端真实的服务器(Real Server)

详细的LVS的介绍可以参考LVS详解.

17. 微服务架构是什么样子的?

通常传统的项目体积庞大,需求、设计、开发、测试、部署流程固定。新功能需要在原项目上做修改。

但是微服务可以看做是对大项目的拆分,是在快速迭代更新上线的需求下产生的。新的功能模块会发布成新的服务组件,与其他已发布的服务组件一同协作。 服务内部有多个生产者和消费者,通常以http rest的方式调用,服务总体以一个(或几个)服务的形式呈现给客户使用。

微服务架构是一种思想对微服务架构我们没有一个明确的定义,但简单来说微服务架构是:

采用一组服务的方式来构建一个应用,服务独立部署在不同的进程中,不同服务通过一些轻量级交互机制来通信,例如 RPC、HTTP 等,服务可独立扩展伸缩,每个服务定义了明确的边界,不同的服务甚至可以采用不同的编程语言来实现,由独立的团队来维护。

Golang的微服务框架kit中有详细的微服务的例子,可以参考学习.

微服务架构设计包括:

  1. 服务熔断降级限流机制 熔断降级的概念(Rate Limiter 限流器,Circuit breaker 断路器).
  2. 框架调用方式解耦方式 Kit 或 Istio 或 Micro 服务发现(consul zookeeper kubeneters etcd ) RPC调用框架.
  3. 链路监控,zipkin和prometheus.
  4. 多级缓存.
  5. 网关 (kong gateway).
  6. Docker部署管理 Kubenetters.
  7. 自动集成部署 CI/CD 实践.
  8. 自动扩容机制规则.
  9. 压测 优化.
  10. Trasport 数据传输(序列化和反序列化).
  11. Logging 日志.
  12. Metrics 指针对每个请求信息的仪表盘化.

微服务架构介绍详细的可以参考:

18. 分布式锁实现原理,用过吗?

在分析分布式锁的三种实现方式之前,先了解一下分布式锁应该具备哪些条件:

  1. 在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行;
  2. 高可用的获取锁与释放锁;
  3. 高性能的获取锁与释放锁;
  4. 具备可重入特性;
  5. 具备锁失效机制,防止死锁;
  6. 具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。

通常分布式锁以单独的服务方式实现,目前比较常用的分布式锁实现有三种:

  • 基于数据库实现分布式锁。
  • 基于缓存(redis,memcached,tair)实现分布式锁。
  • 基于Zookeeper实现分布式锁。

尽管有这三种方案,但是不同的业务也要根据自己的情况进行选型,他们之间没有最好只有更适合!

  • 基于数据库的实现方式

基于数据库的实现方式的核心思想是:在数据库中创建一个表,表中包含方法名等字段,并在方法名字段上创建唯一索引,想要执行某个方法,就使用这个方法名向表中插入数据,成功插入则获取锁,执行完成后删除对应的行数据释放锁。

创建一个表:

DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `method_name` varchar(64) NOT NULL COMMENT '锁定的方法名',
  `desc` varchar(255) NOT NULL COMMENT '备注信息',
  `update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的方法';

想要执行某个方法,就使用这个方法名向表中插入数据:

INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');

因为我们对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

成功插入则获取锁,执行完成后删除对应的行数据释放锁:

delete from method_lock where method_name ='methodName';

注意:这里只是使用基于数据库的一种方法,使用数据库实现分布式锁还有很多其他的用法可以实现!

使用基于数据库的这种实现方式很简单,但是对于分布式锁应该具备的条件来说,它有一些问题需要解决及优化:

1、因为是基于数据库实现的,数据库的可用性和性能将直接影响分布式锁的可用性及性能,所以,数据库需要双机部署、数据同步、主备切换;

2、不具备可重入的特性,因为同一个线程在释放锁之前,行数据一直存在,无法再次成功插入数据,所以,需要在表中新增一列,用于记录当前获取到锁的机器和线程信息,在再次获取锁的时候,先查询表中机器和线程信息是否和当前机器和线程相同,若相同则直接获取锁;

3、没有锁失效机制,因为有可能出现成功插入数据后,服务器宕机了,对应的数据没有被删除,当服务恢复后一直获取不到锁,所以,需要在表中新增一列,用于记录失效时间,并且需要有定时任务清除这些失效的数据;

4、不具备阻塞锁特性,获取不到锁直接返回失败,所以需要优化获取逻辑,循环多次去获取。

5、在实施的过程中会遇到各种不同的问题,为了解决这些问题,实现方式将会越来越复杂;依赖数据库需要一定的资源开销,性能问题需要考虑。

  • 基于Redis的实现方式

选用Redis实现分布式锁原因:

  1. Redis有很高的性能;
  2. Redis命令对此支持较好,实现起来比较方便

主要实现方式:

  1. SET lock currentTime+expireTime EX 600 NX,使用set设置lock值,并设置过期时间为600秒,如果成功,则获取锁;
  2. 获取锁后,如果该节点掉线,则到过期时间ock值自动失效;
  3. 释放锁时,使用del删除lock键值;

使用redis单机来做分布式锁服务,可能会出现单点问题,导致服务可用性差,因此在服务稳定性要求高的场合,官方建议使用redis集群(例如5台,成功请求锁超过3台就认为获取锁),来实现redis分布式锁。详见RedLock。

优点:性能高,redis可持久化,也能保证数据不易丢失,redis集群方式提高稳定性。

缺点:使用redis主从切换时可能丢失部分数据。

  • 基于ZooKeeper的实现方式

ZooKeeper是一个为分布式应用提供一致性服务的开源组件,它内部是一个分层的文件系统目录树结构,规定同一个目录下只能有一个唯一文件名。基于ZooKeeper实现分布式锁的步骤如下:

  1. 创建一个目录mylock;
  2. 线程A想获取锁就在mylock目录下创建临时顺序节点;
  3. 获取mylock目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
  4. 线程B获取所有节点,判断自己不是最小节点,设置监听比自己次小的节点;
  5. 线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。

这里推荐一个Apache的开源库Curator,它是一个ZooKeeper客户端,Curator提供的InterProcessMutex是分布式锁的实现,acquire方法用于获取锁,release方法用于释放锁。

优点:具备高可用、可重入、阻塞锁特性,可解决失效死锁问题。

缺点:因为需要频繁的创建和删除节点,性能上不如Redis方式。

上面的三种实现方式,没有在所有场合都是完美的,所以,应根据不同的应用场景选择最适合的实现方式。

在分布式环境中,对资源进行上锁有时候是很重要的,比如抢购某一资源,这时候使用分布式锁就可以很好地控制资源。

19. Etcd怎么实现分布式锁?

首先思考下Etcd是什么?可能很多人第一反应可能是一个键值存储仓库,却没有重视官方定义的后半句,用于配置共享和服务发现。

A highly-available key value store for shared configuration and service discovery.

实际上,etcd 作为一个受到 ZooKeeper 与 doozer 启发而催生的项目,除了拥有与之类似的功能外,更专注于以下四点。

  • 简单:基于 HTTP+JSON 的 API 让你用 curl 就可以轻松使用。
  • 安全:可选 SSL 客户认证机制。
  • 快速:每个实例每秒支持一千次写操作。
  • 可信:使用 Raft 算法充分实现了分布式。

但是这里我们主要讲述Etcd如何实现分布式锁?

因为 Etcd 使用 Raft 算法保持了数据的强一致性,某次操作存储到集群中的值必然是全局一致的,所以很容易实现分布式锁。锁服务有两种使用方式,一是保持独占,二是控制时序。

  • 保持独占即所有获取锁的用户最终只有一个可以得到。etcd 为此提供了一套实现分布式锁原子操作 CAS(CompareAndSwap)的 API。通过设置prevExist值,可以保证在多个节点同时去创建某个目录时,只有一个成功。而创建成功的用户就可以认为是获得了锁。

  • 控制时序,即所有想要获得锁的用户都会被安排执行,但是获得锁的顺序也是全局唯一的,同时决定了执行顺序。etcd 为此也提供了一套 API(自动创建有序键),对一个目录建值时指定为POST动作,这样 etcd 会自动在目录下生成一个当前最大的值为键,存储这个新的值(客户端编号)。同时还可以使用 API 按顺序列出所有当前目录下的键值。此时这些键的值就是客户端的时序,而这些键中存储的值可以是代表客户端的编号。

在这里etcd实现分布式锁基本实现原理为:

  1. 在etcd系统里创建一个key
  2. 如果创建失败,key存在,则监听该key的变化事件,直到该key被删除,回到1
  3. 如果创建成功,则认为我获得了锁

应用示例:

package etcdsync

import (
	"fmt"
	"io"
	"os"
	"sync"
	"time"

	"github.com/coreos/etcd/client"
	"github.com/coreos/etcd/Godeps/_workspace/src/golang.org/x/net/context"
)

const (
	defaultTTL = 60
	defaultTry = 3
	deleteAction = "delete"
	expireAction = "expire"
)

// A Mutex is a mutual exclusion lock which is distributed across a cluster.
type Mutex struct {
	key    string
	id     string // The identity of the caller
	client client.Client
	kapi   client.KeysAPI
	ctx    context.Context
	ttl    time.Duration
	mutex  *sync.Mutex
	logger io.Writer
}

// New creates a Mutex with the given key which must be the same
// across the cluster nodes.
// machines are the etcd cluster addresses
func New(key string, ttl int, machines []string) *Mutex {
	cfg := client.Config{
		Endpoints:               machines,
		Transport:               client.DefaultTransport,
		HeaderTimeoutPerRequest: time.Second,
	}

	c, err := client.New(cfg)
	if err != nil {
		return nil
	}

	hostname, err := os.Hostname()
	if err != nil {
		return nil
	}

	if len(key) == 0 || len(machines) == 0 {
		return nil
	}

	if key[0] != '/' {
		key = "/" + key
	}

	if ttl < 1 {
		ttl = defaultTTL
	}

	return &Mutex{
		key:    key,
		id:     fmt.Sprintf("%v-%v-%v", hostname, os.Getpid(), time.Now().Format("20060102-15:04:05.999999999")),
		client: c,
		kapi:   client.NewKeysAPI(c),
		ctx: context.TODO(),
		ttl: time.Second * time.Duration(ttl),
		mutex:  new(sync.Mutex),
	}
}

// Lock locks m.
// If the lock is already in use, the calling goroutine
// blocks until the mutex is available.
func (m *Mutex) Lock() (err error) {
	m.mutex.Lock()
	for try := 1; try <= defaultTry; try++ {
		if m.lock() == nil {
			return nil
		}
		
		m.debug("Lock node %v ERROR %v", m.key, err)
		if try < defaultTry {
			m.debug("Try to lock node %v again", m.key, err)
		}
	}
	return err
}

func (m *Mutex) lock() (err error) {
	m.debug("Trying to create a node : key=%v", m.key)
	setOptions := &client.SetOptions{
		PrevExist:client.PrevNoExist,
		TTL:      m.ttl,
	}
	resp, err := m.kapi.Set(m.ctx, m.key, m.id, setOptions)
	if err == nil {
		m.debug("Create node %v OK [%q]", m.key, resp)
		return nil
	}
	m.debug("Create node %v failed [%v]", m.key, err)
	e, ok := err.(client.Error)
	if !ok {
		return err
	}

	if e.Code != client.ErrorCodeNodeExist {
		return err
	}

	// Get the already node's value.
	resp, err = m.kapi.Get(m.ctx, m.key, nil)
	if err != nil {
		return err
	}
	m.debug("Get node %v OK", m.key)
	watcherOptions := &client.WatcherOptions{
		AfterIndex : resp.Index,
		Recursive:false,
	}
	watcher := m.kapi.Watcher(m.key, watcherOptions)
	for {
		m.debug("Watching %v ...", m.key)
		resp, err = watcher.Next(m.ctx)
		if err != nil {
			return err
		}

		m.debug("Received an event : %q", resp)
		if resp.Action == deleteAction || resp.Action == expireAction {
			return nil
		}
	}

}

// Unlock unlocks m.
// It is a run-time error if m is not locked on entry to Unlock.
//
// A locked Mutex is not associated with a particular goroutine.
// It is allowed for one goroutine to lock a Mutex and then
// arrange for another goroutine to unlock it.
func (m *Mutex) Unlock() (err error) {
	defer m.mutex.Unlock()
	for i := 1; i <= defaultTry; i++ {
		var resp *client.Response
		resp, err = m.kapi.Delete(m.ctx, m.key, nil)
		if err == nil {
			m.debug("Delete %v OK", m.key)
			return nil
		}
		m.debug("Delete %v falied: %q", m.key, resp)
		e, ok := err.(client.Error)
		if ok && e.Code == client.ErrorCodeKeyNotFound {
			return nil
		}
	}
	return err
}

func (m *Mutex) debug(format string, v ...interface{}) {
	if m.logger != nil {
		m.logger.Write([]byte(m.id))
		m.logger.Write([]byte(" "))
		m.logger.Write([]byte(fmt.Sprintf(format, v...)))
		m.logger.Write([]byte("\n"))
	}
}

func (m *Mutex) SetDebugLogger(w io.Writer) {
	m.logger = w
}

其实类似的实现有很多,但目前都已经过时,使用的都是被官方标记为deprecated的项目。且大部分接口都不如上述代码简单。 使用上,跟Golang官方sync包的Mutex接口非常类似,先New(),然后调用Lock(),使用完后调用Unlock(),就三个接口,就是这么简单。示例代码如下:

package main

import (
	"github.com/zieckey/etcdsync"
	"log"
)

func main() {
	//etcdsync.SetDebug(true)
	log.SetFlags(log.Ldate|log.Ltime|log.Lshortfile)
	m := etcdsync.New("/etcdsync", "123", []string{"http://127.0.0.1:2379"})
	if m == nil {
		log.Printf("etcdsync.NewMutex failed")
	}
	err := m.Lock()
	if err != nil {
		log.Printf("etcdsync.Lock failed")
	} else {
		log.Printf("etcdsync.Lock OK")
	}

	log.Printf("Get the lock. Do something here.")

	err = m.Unlock()
	if err != nil {
		log.Printf("etcdsync.Unlock failed")
	} else {
		log.Printf("etcdsync.Unlock OK")
	}
}

20. Redis的数据结构有哪些,以及实现场景?

Redis的数据结构有五种:

  • string 字符串

String 数据结构是简单的 key-value 类型,value 不仅可以是 String,也可以是数字(当数字类型用 Long 可以表示的时候encoding 就是整型,其他都存储在 sdshdr 当做字符串)。使用 Strings 类型,可以完全实现目前 Memcached 的功能,并且效率更高。还可以享受 Redis 的定时持久化(可以选择 RDB 模式或者 AOF 模式),操作日志及 Replication 等功能。

除了提供与 Memcached 一样的 get、set、incr、decr 等操作外,Redis 还提供了下面一些操作:

  1. LEN niushuai:O(1)获取字符串长度.
  2. APPEND niushuai redis:往字符串 append 内容,而且采用智能分配内存(每次2倍).
  3. 设置和获取字符串的某一段内容.
  4. 设置及获取字符串的某一位(bit).
  5. 批量设置一系列字符串的内容.
  6. 原子计数器.
  7. GETSET 命令的妙用,请于清空旧值的同时设置一个新值,配合原子计数器使用.
  • Hash 字典

在 Memcached 中,我们经常将一些结构化的信息打包成 hashmap,在客户端序列化后存储为一个字符串的值(一般是 JSON 格式),比如用户的昵称、年龄、性别、积分等。这时候在需要修改其中某一项时,通常需要将字符串(JSON)取出来,然后进行反序列化,修改某一项的值,再序列化成字符串(JSON)存储回去。简单修改一个属性就干这么多事情,消耗必定是很大的,也不适用于一些可能并发操作的场合(比如两个并发的操作都需要修改积分)。而 Redis 的 Hash 结构可以使你像在数据库中 Update 一个属性一样只修改某一项属性值。

Hash可以用来存储、读取、修改用户属性。

  • List 列表

List 说白了就是链表(redis 使用双端链表实现的 List),相信学过数据结构知识的人都应该能理解其结构。使用 List 结构,我们可以轻松地实现最新消息排行等功能(比如新浪微博的 TimeLine )。List 的另一个应用就是消息队列,可以利用 List 的 *PUSH 操作,将任务存在 List 中,然后工作线程再用 POP 操作将任务取出进行执行。

Redis 还提供了操作 List 中某一段元素的 API,你可以直接查询,删除 List 中某一段的元素。

List 列表应用:

  1. 微博 TimeLine.
  2. 消息队列.
  • Set 集合

Set 就是一个集合,集合的概念就是一堆不重复值的组合。利用 Redis 提供的 Set 数据结构,可以存储一些集合性的数据。比如在微博应用中,可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。因为 Redis 非常人性化的为集合提供了求交集、并集、差集等操作,那么就可以非常方便的实现如共同关注、共同喜好、二度好友等功能,对上面的所有集合操作,你还可以使用不同的命令选择将结果返回给客户端还是存集到一个新的集合中。

Set 集合应用:

  1. 共同好友、二度好友
  2. 利用唯一性,可以统计访问网站的所有独立 IP.
  3. 好友推荐的时候,根据 tag 求交集,大于某个 threshold 就可以推荐.
  • Sorted Set有序集合

和Sets相比,Sorted Sets是将 Set 中的元素增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,比如一个存储全班同学成绩的 Sorted Sets,其集合 value 可以是同学的学号,而 score 就可以是其考试得分,这样在数据插入集合的时候,就已经进行了天然的排序。另外还可以用 Sorted Sets 来做带权重的队列,比如普通消息的 score 为1,重要消息的 score 为2,然后工作线程可以选择按 score 的倒序来获取工作任务。让重要的任务优先执行。

Sorted Set有序集合应用:

1.带有权重的元素,比如一个游戏的用户得分排行榜. 2.比较复杂的数据结构,一般用到的场景不算太多.

Redis 其他功能使用场景:

  • 订阅-发布系统

Pub/Sub 从字面上理解就是发布(Publish)与订阅(Subscribe),在 Redis 中,你可以设定对某一个 key 值进行消息发布及消息订阅,当一个 key 值上进行了消息发布后,所有订阅它的客户端都会收到相应的消息。这一功能最明显的用法就是用作实时消息系统,比如普通的即时聊天,群聊等功能。

  • 事务——Transactions

谁说 NoSQL 都不支持事务,虽然 Redis 的 Transactions 提供的并不是严格的 ACID 的事务(比如一串用 EXEC 提交执行的命令,在执行中服务器宕机,那么会有一部分命令执行了,剩下的没执行),但是这个 Transactions 还是提供了基本的命令打包执行的功能(在服务器不出问题的情况下,可以保证一连串的命令是顺序在一起执行的,中间有会有其它客户端命令插进来执行)。Redis 还提供了一个 Watch 功能,你可以对一个 key 进行 Watch,然后再执行 Transactions,在这过程中,如果这个 Watched 的值进行了修改,那么这个 Transactions 会发现并拒绝执行。

21. Mysql高可用方案有哪些?

Mysql高可用方案包括:

  1. 主从复制方案

这是MySQL自身提供的一种高可用解决方案,数据同步方法采用的是MySQL replication技术。MySQL replication就是从服务器到主服务器拉取二进制日志文件,然后再将日志文件解析成相应的SQL在从服务器上重新执行一遍主服务器的操作,通过这种方式保证数据的一致性。为了达到更高的可用性,在实际的应用环境中,一般都是采用MySQL replication技术配合高可用集群软件keepalived来实现自动failover,这种方式可以实现95.000%的SLA。

  1. MMM/MHA高可用方案

MMM提供了MySQL主主复制配置的监控、故障转移和管理的一套可伸缩的脚本套件。在MMM高可用方案中,典型的应用是双主多从架构,通过MySQL replication技术可以实现两个服务器互为主从,且在任何时候只有一个节点可以被写入,避免了多点写入的数据冲突。同时,当可写的主节点故障时,MMM套件可以立刻监控到,然后将服务自动切换到另一个主节点,继续提供服务,从而实现MySQL的高可用。

  1. Heartbeat/SAN高可用方案

在这个方案中,处理failover的方式是高可用集群软件Heartbeat,它监控和管理各个节点间连接的网络,并监控集群服务,当节点出现故障或者服务不可用时,自动在其他节点启动集群服务。在数据共享方面,通过SAN(Storage Area Network)存储来共享数据,这种方案可以实现99.990%的SLA。

  1. Heartbeat/DRBD高可用方案

这个方案处理failover的方式上依旧采用Heartbeat,不同的是,在数据共享方面,采用了基于块级别的数据同步软件DRBD来实现。DRBD是一个用软件实现的、无共享的、服务器之间镜像块设备内容的存储复制解决方案。和SAN网络不同,它并不共享存储,而是通过服务器之间的网络复制数据。

  1. NDB CLUSTER高可用方案

国内用NDB集群的公司非常少,貌似有些银行有用。NDB集群不需要依赖第三方组件,全部都使用官方组件,能保证数据的一致性,某个数据节点挂掉,其他数据节点依然可以提供服务,管理节点需要做冗余以防挂掉。缺点是:管理和配置都很复杂,而且某些SQL语句例如join语句需要避免。

22. Go语言的栈空间管理是怎么样的?

Go语言的运行环境(runtime)会在goroutine需要的时候动态地分配栈空间,而不是给每个goroutine分配固定大小的内存空间。这样就避免了需要程序员来决定栈的大小。

分块式的栈是最初Go语言组织栈的方式。当创建一个goroutine的时候,它会分配一个8KB的内存空间来给goroutine的栈使用。我们可能会考虑当这8KB的栈空间被用完的时候该怎么办?

为了处理这种情况,每个Go函数的开头都有一小段检测代码。这段代码会检查我们是否已经用完了分配的栈空间。如果是的话,它会调用morestack函数。morestack函数分配一块新的内存作为栈空间,并且在这块栈空间的底部填入各种信息(包括之前的那块栈地址)。在分配了这块新的栈空间之后,它会重试刚才造成栈空间不足的函数。这个过程叫做栈分裂(stack split)。

在新分配的栈底部,还插入了一个叫做lessstack的函数指针。这个函数还没有被调用。这样设置是为了从刚才造成栈空间不足的那个函数返回时做准备的。当我们从那个函数返回时,它会跳转到lessstacklessstack函数会查看在栈底部存放的数据结构里的信息,然后调整栈指针(stack pointer)。这样就完成了从新的栈块到老的栈块的跳转。接下来,新分配的这个块栈空间就可以被释放掉了。

分块式的栈让我们能够按照需求来扩展和收缩栈的大小。 Go开发者不需要花精力去估计goroutine会用到多大的栈。创建一个新的goroutine的开销也不大。当 Go开发者不知道栈会扩展到多少大时,它也能很好的处理这种情况。

这一直是之前Go语言管理栈的的方法。但这个方法有一个问题。缩减栈空间是一个开销相对较大的操作。如果在一个循环里有栈分裂,那么它的开销就变得不可忽略了。一个函数会扩展,然后分裂栈。当它返回的时候又会释放之前分配的内存块。如果这些都发生在一个循环里的话,代价是相当大的。 这就是所谓的热分裂问题(hot split problem)。它是Go语言开发者选择新的栈管理方法的主要原因。新的方法叫做栈复制法(stack copying)

栈复制法一开始和分块式的栈很像。当goroutine运行并用完栈空间的时候,与之前的方法一样,栈溢出检查会被触发。但是,不像之前的方法那样分配一个新的内存块并链接到老的栈内存块,新的方法会分配一个两倍大的内存块并把老的内存块内容复制到新的内存块里。这样做意味着当栈缩减回之前大小时,我们不需要做任何事情。栈的缩减没有任何代价。而且,当栈再次扩展时,运行环境也不需要再做任何事。它可以重用之前分配的空间。

栈的复制听起来很容易,但实际操作并非那么简单。存储在栈上的变量的地址可能已经被使用到。也就是说程序使用到了一些指向栈的指针。当移动栈的时候,所有指向栈里内容的指针都会变得无效。然而,指向栈内容的指针自身也必定是保存在栈上的。这是为了保证内存安全的必要条件。否则一个程序就有可能访问一段已经无效的栈空间了。

因为垃圾回收的需要,我们必须知道栈的哪些部分是被用作指针了。当我们移动栈的时候,我们可以更新栈里的指针让它们指向新的地址。所有相关的指针都会被更新。我们使用了垃圾回收的信息来复制栈,但并不是任何使用栈的函数都有这些信息。因为很大一部分运行环境是用C语言写的,很多被调用的运行环境里的函数并没有指针的信息,所以也就不能够被复制了。当遇到这种情况时,我们只能退回到分块式的栈并支付相应的开销。

这也是为什么现在运行环境的开发者正在用Go语言重写运行环境的大部分代码。无法用Go语言重写的部分(比如调度器的核心代码和垃圾回收器)会在特殊的栈上运行。这个特殊栈的大小由运行环境的开发者设置。

这些改变除了使栈复制成为可能,它也允许我们在将来实现并行垃圾回收。

另外一种不同的栈处理方式就是在虚拟内存中分配大内存段。由于物理内存只是在真正使用时才会被分配,因此看起来好似你可以分配一个大内存段并让操 作系统处理它。下面是这种方法的一些问题

首先,32位系统只能支持4G字节虚拟内存,并且应用只能用到其中的3G空间。由于同时运行百万goroutines的情况并不少见,因此你很可 能用光虚拟内存,即便我们假设每个goroutine的stack只有8K。

第二,然而我们可以在64位系统中分配大内存,它依赖于过量内存使用。所谓过量使用是指当你分配的内存大小超出物理内存大小时,依赖操作系统保证 在需要时能够分配出物理内存。然而,允许过量使用可能会导致一些风险。由于一些进程分配了超出机器物理内存大小的内存,如果这些进程使用更多内存 时,操作系统将不得不为它们补充分配内存。这会导致操作系统将一些内存段放入磁盘缓存,这常常会增加不可预测的处理延迟。正是考虑到这个原因,一 些新系统关闭了对过量使用的支持。

23. Goroutine和Channel的作用分别是什么?

进程是内存资源管理和cpu调度的执行单元。为了有效利用多核处理器的优势,将进程进一步细分,允许一个进程里存在多个线程,这多个线程还是共享同一片内存空间,但cpu调度的最小单元变成了线程。

那协程又是什么呢,以及与线程的差异性??

协程,可以看作是轻量级的线程。但与线程不同的是,线程的切换是由操作系统控制的,而协程的切换则是由用户控制的。

最早支持协程的程序语言应该是lisp方言scheme里的continuation(续延),续延允许scheme保存任意函数调用的现场,保存起来并重新执行。Lua,C#,python等语言也有自己的协程实现。

Go中的goroutinue就是协程,可以实现并行,多个协程可以在多个处理器同时跑。而协程同一时刻只能在一个处理器上跑(可以把宿主语言想象成单线程的就好了)。 然而,多个goroutine之间的通信是通过channel,而协程的通信是通过yield和resume()操作。

goroutine非常简单,只需要在函数的调用前面加关键字go即可,例如:

go elegance()

我们也可以启动5个goroutines分别打印索引。

func main() {
	for i:=1;i<5;i++ {
		go func(i int) {
			fmt.Println(i)
		}(i)
	}
	// 停歇5s,保证打印全部结束
	time.Sleep(5*time.Second)
}

在分析goroutine执行的随机性和并发性,启动了5个goroutine,再加上main函数的主goroutine,总共有6个goroutines。由于goroutine类似于”守护线程“,异步执行的,如果主goroutine不等待片刻,可能程序就没有输出打印了。

在Golang中channel则是goroutinues之间进行通信的渠道。

可以把channel形象比喻为工厂里的传送带,一头的生产者goroutine往传输带放东西,另一头的消费者goroutinue则从输送带取东西。channel实际上是一个有类型的消息队列,遵循先进先出的特点。

  1. channel的操作符号

ch <- data 表示data被发送给channel ch;

data <- ch 表示从channel ch取一个值,然后赋给data。

  1. 阻塞式channel

channel默认是没有缓冲区的,也就是说,通信是阻塞的。send操作必须等到有消费者accept才算完成。

应用示例:

func main() {
	ch1 := make(chan int)
	go pump(ch1) // pump hangs
	fmt.Println(<-ch1) // prints only 1
}

func pump(ch chan int) {
	for i:= 1; ; i++ {
		ch <- i
	}
}

在函数pump()里的channel在接受到第一个元素后就被阻塞了,直到主goroutinue取走了数据。最终channel阻塞在接受第二个元素,程序只打印 1。

没有缓冲(buffer)的channel只能容纳一个元素,而带有缓冲(buffer)channel则可以非阻塞容纳N个元素。发送数据到缓冲(buffer) channel不会被阻塞,除非channel已满;同样的,从缓冲(buffer) channel取数据也不会被阻塞,除非channel空了。

24. 怎么查看Goroutine的数量?

在Golang中,GOMAXPROCS中控制的是未被阻塞的所有Goroutine,可以被Multiplex到多少个线程上运行,通过GOMAXPROCS可以查看Goroutine的数量。

25. 说下Go中的锁有哪些?三种锁,读写锁,互斥锁,还有map的安全的锁?

Go中的三种锁包括:互斥锁,读写锁,sync.Map的安全的锁.

  • 互斥锁

Go并发程序对共享资源进行访问控制的主要手段,由标准库代码包中sync中的Mutex结构体表示。

//Mutex 是互斥锁, 零值是解锁的互斥锁, 首次使用后不得复制互斥锁。
type Mutex struct {
   state int32
   sema  uint32
}

sync.Mutex包中的类型只有两个公开的指针方法Lock和Unlock。

// Locker表示可以锁定和解锁的对象。
type Locker interface {
   Lock()
   Unlock()
}

// 锁定当前的互斥量
// 如果锁已被使用,则调用goroutine
// 阻塞直到互斥锁可用。
func (m *Mutex) Lock() 

// 对当前互斥量进行解锁
// 如果在进入解锁时未锁定m,则为运行时错误。
// 锁定的互斥锁与特定的goroutine无关。
// 允许一个goroutine锁定Mutex然后安排另一个goroutine来解锁它。
func (m *Mutex) Unlock()

声明一个互斥锁:

var mutex sync.Mutex

不像C或Java的锁类工具,我们可能会犯一个错误:忘记及时解开已被锁住的锁,从而导致流程异常。但Go由于存在defer,所以此类问题出现的概率极低。关于defer解锁的方式如下:

var mutex sync.Mutex
func Write()  {
   mutex.Lock()
   defer mutex.Unlock()
}

如果对一个已经上锁的对象再次上锁,那么就会导致该锁定操作被阻塞,直到该互斥锁回到被解锁状态.

fpackage main

import (
	"fmt"
	"sync"
	"time"
)

func main() {

	var mutex sync.Mutex
	fmt.Println("begin lock")
	mutex.Lock()
	fmt.Println("get locked")
	for i := 1; i <= 3; i++ {
		go func(i int) {
			fmt.Println("begin lock ", i)
			mutex.Lock()
			fmt.Println("get locked ", i)
		}(i)
	}

	time.Sleep(time.Second)
	fmt.Println("Unlock the lock")
	mutex.Unlock()
	fmt.Println("get unlocked")
	time.Sleep(time.Second)
}

我们在for循环之前开始加锁,然后在每一次循环中创建一个协程,并对其加锁,但是由于之前已经加锁了,所以这个for循环中的加锁会陷入阻塞直到main中的锁被解锁, time.Sleep(time.Second) 是为了能让系统有足够的时间运行for循环,输出结果如下:

> go run mutex.go 
begin lock
get locked
begin lock  3
begin lock  1
begin lock  2
Unlock the lock
get unlocked
get locked  3

这里可以看到解锁后,三个协程会重新抢夺互斥锁权,最终协程3获胜。

互斥锁锁定操作的逆操作并不会导致协程阻塞,但是有可能导致引发一个无法恢复的运行时的panic,比如对一个未锁定的互斥锁进行解锁时就会发生panic。避免这种情况的最有效方式就是使用defer。

我们知道如果遇到panic,可以使用recover方法进行恢复,但是如果对重复解锁互斥锁引发的panic却是无用的(Go 1.8及以后)。

package main

import (
	"fmt"
	"sync"
)

func main() {
	defer func() {
		fmt.Println("Try to recover the panic")
		if p := recover(); p != nil {
			fmt.Println("recover the panic : ", p)
		}
	}()
	var mutex sync.Mutex
	fmt.Println("begin lock")
	mutex.Lock()
	fmt.Println("get locked")
	fmt.Println("unlock lock")
	mutex.Unlock()
	fmt.Println("lock is unlocked")
	fmt.Println("unlock lock again")
	mutex.Unlock()
}

运行:

> go run mutex.go 
begin lock
get locked
unlock lock
lock is unlocked
unlock lock again
fatal error: sync: unlock of unlocked mutex

goroutine 1 [running]:
runtime.throw(0x4bc1a8, 0x1e)
        /home/keke/soft/go/src/runtime/panic.go:617 +0x72 fp=0xc000084ea8 sp=0xc000084e78 pc=0x427ba2
sync.throw(0x4bc1a8, 0x1e)
        /home/keke/soft/go/src/runtime/panic.go:603 +0x35 fp=0xc000084ec8 sp=0xc000084ea8 pc=0x427b25
sync.(*Mutex).Unlock(0xc00001a0c8)
        /home/keke/soft/go/src/sync/mutex.go:184 +0xc1 fp=0xc000084ef0 sp=0xc000084ec8 pc=0x45f821
main.main()
        /home/keke/go/Test/mutex.go:25 +0x25f fp=0xc000084f98 sp=0xc000084ef0 pc=0x486c1f
runtime.main()
        /home/keke/soft/go/src/runtime/proc.go:200 +0x20c fp=0xc000084fe0 sp=0xc000084f98 pc=0x4294ec
runtime.goexit()
        /home/keke/soft/go/src/runtime/asm_amd64.s:1337 +0x1 fp=0xc000084fe8 sp=0xc000084fe0 pc=0x450ad1
exit status 2

这里试图对重复解锁引发的panic进行recover,但是我们发现操作失败,虽然互斥锁可以被多个协程共享,但还是建议将对同一个互斥锁的加锁解锁操作放在同一个层次的代码中。

  • 读写锁

读写锁是针对读写操作的互斥锁,可以分别针对读操作与写操作进行锁定和解锁操作 。

读写锁的访问控制规则如下:

① 多个写操作之间是互斥的 ② 写操作与读操作之间也是互斥的 ③ 多个读操作之间不是互斥的

在这样的控制规则下,读写锁可以大大降低性能损耗。

在Go的标准库代码包中sync中的RWMutex结构体表示为:

// RWMutex是一个读/写互斥锁,可以由任意数量的读操作或单个写操作持有。
// RWMutex的零值是未锁定的互斥锁。
// 首次使用后,不得复制RWMutex。
// 如果goroutine持有RWMutex进行读取而另一个goroutine可能会调用Lock,那么在释放初始读锁之前,goroutine不应该期望能够获取读锁定。 
// 特别是,这种禁止递归读锁定。 这是为了确保锁最终变得可用; 阻止的锁定会阻止新读操作获取锁定。
type RWMutex struct {
   w           Mutex  //如果有待处理的写操作就持有
   writerSem   uint32 // 写操作等待读操作完成的信号量
   readerSem   uint32 //读操作等待写操作完成的信号量
   readerCount int32  // 待处理的读操作数量
   readerWait  int32  // number of departing readers
}

sync中的RWMutex有以下几种方法:

//对读操作的锁定
func (rw *RWMutex) RLock()
//对读操作的解锁
func (rw *RWMutex) RUnlock()
//对写操作的锁定
func (rw *RWMutex) Lock()
//对写操作的解锁
func (rw *RWMutex) Unlock()

//返回一个实现了sync.Locker接口类型的值,实际上是回调rw.RLock and rw.RUnlock.
func (rw *RWMutex) RLocker() Locker

Unlock方法会试图唤醒所有想进行读锁定而被阻塞的协程,而 RUnlock方法只会在已无任何读锁定的情况下,试图唤醒一个因欲进行写锁定而被阻塞的协程。若对一个未被写锁定的读写锁进行写解锁,就会引发一个不可恢复的panic,同理对一个未被读锁定的读写锁进行读写锁也会如此。

由于读写锁控制下的多个读操作之间不是互斥的,因此对于读解锁更容易被忽视。对于同一个读写锁,添加多少个读锁定,就必要有等量的读解锁,这样才能其他协程有机会进行操作。

package main

import (
	"fmt"
	"sync"
	"time"
)

func main() {
	var rwm sync.RWMutex
	for i := 0; i < 5; i++ {
		go func(i int) {
			fmt.Println("try to lock read ", i)
			rwm.RLock()
			fmt.Println("get locked ", i)
			time.Sleep(time.Second * 2)
			fmt.Println("try to unlock for reading ", i)
			rwm.RUnlock()
			fmt.Println("unlocked for reading ", i)
		}(i)
	}
	time.Sleep(time.Millisecond * 1000)
	fmt.Println("try to lock for writing")
	rwm.Lock()
	fmt.Println("locked for writing")
}

运行:

> go run rwmutex.go 
try to lock read  0
get locked  0
try to lock read  4
get locked  4
try to lock read  3
get locked  3
try to lock read  1
get locked  1
try to lock read  2
get locked  2
try to lock for writing
try to unlock for reading  0
unlocked for reading  0
try to unlock for reading  2
unlocked for reading  2
try to unlock for reading  1
unlocked for reading  1
try to unlock for reading  3
unlocked for reading  3
try to unlock for reading  4
unlocked for reading  4
locked for writing

这里可以看到创建了五个协程用于对读写锁的读锁定与读解锁操作。在 rwm.Lock()种会对main中协程进行写锁定,但是for循环中的读解锁尚未完成,因此会造成main中的协程阻塞。当for循环中的读解锁操作都完成后就会试图唤醒main中阻塞的协程,main中的写锁定才会完成。

  • sync.Map安全锁

golang中的sync.Map是并发安全的,其实也就是sync包中golang自定义的一个名叫Map的结构体。

应用示例:

package main
import (
    "sync"
    "fmt"
)

func main() {
    //开箱即用
    var sm sync.Map
    //store 方法,添加元素
    sm.Store(1,"a")
    //Load 方法,获得value
    if v,ok:=sm.Load(1);ok{
        fmt.Println(v)
    }
    //LoadOrStore方法,获取或者保存
    //参数是一对key:value,如果该key存在且没有被标记删除则返回原先的value(不更新)和true;不存在则store,返回该value 和false
    if vv,ok:=sm.LoadOrStore(1,"c");ok{
        fmt.Println(vv)
    }
    if vv,ok:=sm.LoadOrStore(2,"c");!ok{
        fmt.Println(vv)
    }
    //遍历该map,参数是个函数,该函数参的两个参数是遍历获得的key和value,返回一个bool值,当返回false时,遍历立刻结束。
    sm.Range(func(k,v interface{})bool{
        fmt.Print(k)
        fmt.Print(":")
        fmt.Print(v)
        fmt.Println()
        return true
    })
}

运行 :

a
a
c
1:a
2:c

sync.Map的数据结构:

 type Map struct {
    // 该锁用来保护dirty
    mu Mutex
    // 存读的数据,因为是atomic.value类型,只读类型,所以它的读是并发安全的
    read atomic.Value // readOnly
    //包含最新的写入的数据,并且在写的时候,会把read 中未被删除的数据拷贝到该dirty中,因为是普通的map存在并发安全问题,需要用到上面的mu字段。
    dirty map[interface{}]*entry
    // 从read读数据的时候,会将该字段+1,当等于len(dirty)的时候,会将dirty拷贝到read中(从而提升读的性能)。
    misses int
}

read的数据结构是:

type readOnly struct {
    m  map[interface{}]*entry
    // 如果Map.dirty的数据和m 中的数据不一样是为true
    amended bool 
}

entry的数据结构:

type entry struct {
    //可见value是个指针类型,虽然read和dirty存在冗余情况(amended=false),但是由于是指针类型,存储的空间应该不是问题
    p unsafe.Pointer // *interface{}
}

Delete 方法:

func (m *Map) Delete(key interface{}) {
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    //如果read中没有,并且dirty中有新元素,那么就去dirty中去找
    if !ok && read.amended {
        m.mu.Lock()
        //这是双检查(上面的if判断和锁不是一个原子性操作)
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        if !ok && read.amended {
            //直接删除
            delete(m.dirty, key)
        }
        m.mu.Unlock()
    }
    if ok {
    //如果read中存在该key,则将该value 赋值nil(采用标记的方式删除!)
        e.delete()
    }
}

func (e *entry) delete() (hadValue bool) {
    for {
        p := atomic.LoadPointer(&e.p)
        if p == nil || p == expunged {
            return false
        }
        if atomic.CompareAndSwapPointer(&e.p, p, nil) {
            return true
        }
    }
}

Store 方法:

func (m *Map) Store(key, value interface{}) {
    // 如果m.read存在这个key,并且没有被标记删除,则尝试更新。
    read, _ := m.read.Load().(readOnly)
    if e, ok := read.m[key]; ok && e.tryStore(&value) {
        return
    }
    // 如果read不存在或者已经被标记删除
    m.mu.Lock()
    read, _ = m.read.Load().(readOnly)
    if e, ok := read.m[key]; ok {
    //如果entry被标记expunge,则表明dirty没有key,可添加入dirty,并更新entry
        if e.unexpungeLocked() { 
            //加入dirty中
            m.dirty[key] = e
        }
        //更新value值
        e.storeLocked(&value) 
        //dirty 存在该key,更新
    } else if e, ok := m.dirty[key]; ok { 
        e.storeLocked(&value)
        //read 和dirty都没有,新添加一条
    } else {
     //dirty中没有新的数据,往dirty中增加第一个新键
        if !read.amended { 
            //将read中未删除的数据加入到dirty中
            m.dirtyLocked() 
            m.read.Store(readOnly{m: read.m, amended: true})
        }
        m.dirty[key] = newEntry(value) 
    }
    m.mu.Unlock()
}

//将read中未删除的数据加入到dirty中
func (m *Map) dirtyLocked() {
    if m.dirty != nil {
        return
    }
    read, _ := m.read.Load().(readOnly)
    m.dirty = make(map[interface{}]*entry, len(read.m))
    //read如果较大的话,可能影响性能
    for k, e := range read.m {
    //通过此次操作,dirty中的元素都是未被删除的,可见expunge的元素不在dirty中
        if !e.tryExpungeLocked() {
            m.dirty[k] = e
        }
    }
}

//判断entry是否被标记删除,并且将标记为nil的entry更新标记为expunge
func (e *entry) tryExpungeLocked() (isExpunged bool) {
    p := atomic.LoadPointer(&e.p)
    for p == nil {
        // 将已经删除标记为nil的数据标记为expunged
        if atomic.CompareAndSwapPointer(&e.p, nil, expunged) {
            return true
        }
        p = atomic.LoadPointer(&e.p)
    }
    return p == expunged
}

//对entry 尝试更新
func (e *entry) tryStore(i *interface{}) bool {
    p := atomic.LoadPointer(&e.p)
    if p == expunged {
        return false
    }
    for {
        if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
            return true
        }
        p = atomic.LoadPointer(&e.p)
        if p == expunged {
            return false
        }
    }
}

//read里 将标记为expunge的更新为nil
func (e *entry) unexpungeLocked() (wasExpunged bool) {
    return atomic.CompareAndSwapPointer(&e.p, expunged, nil)
}

//更新entry
func (e *entry) storeLocked(i *interface{}) {
    atomic.StorePointer(&e.p, unsafe.Pointer(i))
}

因此,每次操作先检查read,因为read 并发安全,性能好些;read不满足,则加锁检查dirty,一旦是新的键值,dirty会被read更新。

Load方法:

Load方法是一个加载方法,查找key。

func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
    //因read只读,线程安全,先查看是否满足条件
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    //如果read没有,并且dirty有新数据,那从dirty中查找,由于dirty是普通map,线程不安全,这个时候用到互斥锁了
    if !ok && read.amended {
        m.mu.Lock()
        // 双重检查
        read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        // 如果read中还是不存在,并且dirty中有新数据
        if !ok && read.amended {
            e, ok = m.dirty[key]
            // mssLocked()函数是性能是sync.Map 性能得以保证的重要函数,目的讲有锁的dirty数据,替换到只读线程安全的read里
            m.missLocked()
        }
        m.mu.Unlock()
    }
    if !ok {
        return nil, false
    }
    return e.load()
}

//dirty 提升至read 关键函数,当misses 经过多次因为load之后,大小等于len(dirty)时候,讲dirty替换到read里,以此达到性能提升。
func (m *Map) missLocked() {
    m.misses++
    if m.misses < len(m.dirty) {
        return
    }
    //原子操作,耗时很小
    m.read.Store(readOnly{m: m.dirty})
    m.dirty = nil
    m.misses = 0
}

sync.Map是通过冗余的两个数据结构(read、dirty),实现性能的提升。为了提升性能,load、delete、store等操作尽量使用只读的read;为了提高read的key击中概率,采用动态调整,将dirty数据提升为read;对于数据的删除,采用延迟标记删除法,只有在提升dirty的时候才删除。

26. 读写锁或者互斥锁读的时候能写吗?

Go中读写锁包括读锁和写锁,多个读线程可以同时访问共享数据;写线程必须等待所有读线程都释放锁以后,才能取得锁;同样的,读线程必须等待写线程释放锁后,才能取得锁,也就是说读写锁要确保的是如下互斥关系,可以同时读,但是读-写,写-写都是互斥的。

27. 怎么限制Goroutine的数量.

在Golang中,Goroutine虽然很好,但是数量太多了,往往会带来很多麻烦,比如耗尽系统资源导致程序崩溃,或者CPU使用率过高导致系统忙不过来。所以我们可以限制下Goroutine的数量,这样就需要在每一次执行go之前判断goroutine的数量,如果数量超了,就要阻塞go的执行。第一时间想到的就是使用通道。每次执行的go之前向通道写入值,直到通道满的时候就阻塞了,

package main

import "fmt"

var ch chan  int

func elegance(){
	<-ch
	fmt.Println("the ch value receive",ch)
}

func main(){
	ch = make(chan int,5)
	for i:=0;i<10;i++{
		ch <-1
		fmt.Println("the ch value send",ch)
		go elegance()
		fmt.Println("the result i",i)
	}

}

运行:

> go run goroutine.go 
the ch value send 0xc00009c000
the result i 0
the ch value send 0xc00009c000
the result i 1
the ch value send 0xc00009c000
the result i 2
the ch value send 0xc00009c000
the result i 3
the ch value send 0xc00009c000
the result i 4
the ch value send 0xc00009c000
the result i 5
the ch value send 0xc00009c000
the ch value receive 0xc00009c000
the result i 6
the ch value receive 0xc00009c000
the ch value send 0xc00009c000
the result i 7
the ch value send 0xc00009c000
the result i 8
the ch value send 0xc00009c000
the result i 9
the ch value send 0xc00009c000
the ch value receive 0xc00009c000
the ch value receive 0xc00009c000
the ch value receive 0xc00009c000
the result i 10
the ch value send 0xc00009c000
the result i 11
the ch value send 0xc00009c000
the result i 12
the ch value send 0xc00009c000
the result i 13
the ch value send 0xc00009c000
the ch value receive 0xc00009c000
the ch value receive 0xc00009c000
the ch value receive 0xc00009c000
the ch value receive 0xc00009c000
the result i 14
the ch value receive 0xc00009c000
> go run goroutine.go 
the ch value send 0xc00007e000
the result i 0
the ch value send 0xc00007e000
the result i 1
the ch value send 0xc00007e000
the result i 2
the ch value send 0xc00007e000
the result i 3
the ch value send 0xc00007e000
the ch value receive 0xc00007e000
the result i 4
the ch value send 0xc00007e000
the ch value receive 0xc00007e000
the result i 5
the ch value send 0xc00007e000
the ch value receive 0xc00007e000
the result i 6
the ch value send 0xc00007e000
the result i 7
the ch value send 0xc00007e000
the ch value receive 0xc00007e000
the ch value receive 0xc00007e000
the ch value receive 0xc00007e000
the result i 8
the ch value send 0xc00007e000
the result i 9

这样每次同时运行的goroutine就被限制为5个了。但是新的问题于是就出现了,因为并不是所有的goroutine都执行完了,在main函数退出之后,还有一些goroutine没有执行完就被强制结束了。这个时候我们就需要用到sync.WaitGroup。使用WaitGroup等待所有的goroutine退出。

package main

import (
	"fmt"
	"runtime"
	"sync"
	"time"
)
// Pool Goroutine Pool
type Pool struct {
	queue chan int
	wg *sync.WaitGroup
}
// New 新建一个协程池
func NewPool(size int) *Pool{
	if size <=0{
		size = 1
	}
	return &Pool{
		queue:make(chan int,size),
		wg:&sync.WaitGroup{},
	}
}
// Add 新增一个执行
func (p *Pool)Add(delta int){
	// delta为正数就添加
	for i :=0;i<delta;i++{
		p.queue <-1
	}
	// delta为负数就减少
	for i:=0;i>delta;i--{
		<-p.queue
	}
	p.wg.Add(delta)
}
// Done 执行完成减一
func (p *Pool) Done(){
	<-p.queue
	p.wg.Done()
}
// Wait 等待Goroutine执行完毕
func (p *Pool) Wait(){
	p.wg.Wait()
}

func main(){
	// 这里限制5个并发
	pool := NewPool(5)
	fmt.Println("the NumGoroutine begin is:",runtime.NumGoroutine())
	for i:=0;i<20;i++{
		pool.Add(1)
		go func(i int) {
			time.Sleep(time.Second)
			fmt.Println("the NumGoroutine continue is:",runtime.NumGoroutine())
			pool.Done()
		}(i)
	}
	pool.Wait()
	fmt.Println("the NumGoroutine done is:",runtime.NumGoroutine())
}

运行:

the NumGoroutine begin is: 1
the NumGoroutine continue is: 6
the NumGoroutine continue is: 7
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 6
the NumGoroutine continue is: 3
the NumGoroutine continue is: 2
the NumGoroutine done is: 1

其中,Go的GOMAXPROCS默认值已经设置为CPU的核数, 这里允许我们的Go程序充分使用机器的每一个CPU,最大程度的提高我们程序的并发性能。runtime.NumGoroutine函数在被调用后,会返回系统中的处于特定状态的Goroutine的数量。这里的特指是指Grunnable\Gruning\Gsyscall\Gwaition。处于这些状态的Groutine即被看做是活跃的或者说正在被调度。

这里需要注意下:垃圾回收所在Groutine的状态也处于这个范围内的话,也会被纳入该计数器。

28. Channel是同步的还是异步的.

Channel是异步进行的, channel存在3种状态:

  • nil,未初始化的状态,只进行了声明,或者手动赋值为nil
  • active,正常的channel,可读或者可写
  • closed,已关闭,千万不要误认为关闭channel后,channel的值是nil

29. 说一下异步和非阻塞的区别?

  • 异步和非阻塞的区别:
  1. 异步:调用在发出之后,这个调用就直接返回,不管有无结果;异步是过程。
  2. 非阻塞:关注的是程序在等待调用结果(消息,返回值)时的状态,指在不能立刻得到结果之前,该调用不会阻塞当前线程。
  • 同步和异步的区别:
  1. 步:一个服务的完成需要依赖其他服务时,只有等待被依赖的服务完成后,才算完成,这是一种可靠的服务序列。要么成功都成功,失败都失败,服务的状态可以保持一致。
  2. 异步:一个服务的完成需要依赖其他服务时,只通知其他依赖服务开始执行,而不需要等待被依赖的服务完成,此时该服务就算完成了。被依赖的服务是否最终完成无法确定,一次它是一个不可靠的服务序列。
  • 消息通知中的同步和异步:
  1. 同步:当一个同步调用发出后,调用者要一直等待返回消息(或者调用结果)通知后,才能进行后续的执行。
  2. 异步:当一个异步过程调用发出后,调用者不能立刻得到返回消息(结果)。在调用结束之后,通过消息回调来通知调用者是否调用成功。
  • 阻塞与非阻塞的区别:
  1. 阻塞:阻塞调用是指调用结果返回之前,当前线程会被挂起,一直处于等待消息通知,不能够执行其他业务,函数只有在得到结果之后才会返回。
  2. 非阻塞:非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。

同步与异步是对应的,它们是线程之间的关系,两个线程之间要么是同步的,要么是异步的。

阻塞与非阻塞是对同一个线程来说的,在某个时刻,线程要么处于阻塞,要么处于非阻塞。

阻塞是使用同步机制的结果,非阻塞则是使用异步机制的结果。

30. Log包线程安全吗?

Golang的标准库提供了log的机制,但是该模块的功能较为简单(看似简单,其实他有他的设计思路)。在输出的位置做了线程安全的保护。

31. Goroutine和线程的区别?

从调度上看,goroutine的调度开销远远小于线程调度开销。

OS的线程由OS内核调度,每隔几毫秒,一个硬件时钟中断发到CPU,CPU调用一个调度器内核函数。这个函数暂停当前正在运行的线程,把他的寄存器信息保存到内存中,查看线程列表并决定接下来运行哪一个线程,再从内存中恢复线程的注册表信息,最后继续执行选中的线程。这种线程切换需要一个完整的上下文切换:即保存一个线程的状态到内存,再恢复另外一个线程的状态,最后更新调度器的数据结构。某种意义上,这种操作还是很慢的。

Go运行的时候包涵一个自己的调度器,这个调度器使用一个称为一个M:N调度技术,m个goroutine到n个os线程(可以用GOMAXPROCS来控制n的数量),Go的调度器不是由硬件时钟来定期触发的,而是由特定的go语言结构来触发的,他不需要切换到内核语境,所以调度一个goroutine比调度一个线程的成本低很多。

从栈空间上,goroutine的栈空间更加动态灵活。

每个OS的线程都有一个固定大小的栈内存,通常是2MB,栈内存用于保存在其他函数调用期间哪些正在执行或者临时暂停的函数的局部变量。这个固定的栈大小,如果对于goroutine来说,可能是一种巨大的浪费。作为对比goroutine在生命周期开始只有一个很小的栈,典型情况是2KB, 在go程序中,一次创建十万左右的goroutine也不罕见(2KB*100,000=200MB)。而且goroutine的栈不是固定大小,它可以按需增大和缩小,最大限制可以到1GB。

goroutine没有一个特定的标识。

在大部分支持多线程的操作系统和编程语言中,线程有一个独特的标识,通常是一个整数或者指针,这个特性可以让我们构建一个线程的局部存储,本质是一个全局的map,以线程的标识作为键,这样每个线程可以独立使用这个map存储和获取值,不受其他线程干扰。

goroutine中没有可供程序员访问的标识,原因是一种纯函数的理念,不希望滥用线程局部存储导致一个不健康的超距作用,即函数的行为不仅取决于它的参数,还取决于运行它的线程标识。

32. 滑动窗口的概念以及应用?

滑窗(sliding window)被同时应用于接收方和发送方。发送方和接收方各有一个滑窗。当片段位于滑窗中时,表示TCP正在处理该片段。滑窗中可以有多个片段,也就是可以同时处理多个片段。滑窗越大,越大的滑窗同时处理的片段数目越多(当然,计算机也必须分配出更多的缓存供滑窗使用)。

滑动窗口概念不仅存在于数据链路层,也存在于传输层,两者有不同的协议,但基本原理是相近的。其中一个重要区别是,一个是针对于帧的传送,另一个是字节数据的传送。

滑动窗口(Sliding window)是一种流量控制技术。早期的网络通信中,通信双方不会考虑网络的拥挤情况直接发送数据。由于大家不知道网络拥塞状况,同时发送数据,导致中间节点阻塞掉包,谁也发不了数据,所以就有了滑动窗口机制来解决此问题。参见滑动窗口如何根据网络拥塞发送数据仿真视频。

滑动窗口协议是用来改善吞吐量的一种技术,即容许发送方在接收任何应答之前传送附加的包。接收方告诉发送方在某一时刻能送多少包(称窗口尺寸)。

CP中采用滑动窗口来进行传输控制,滑动窗口的大小意味着接收方还有多大的缓冲区可以用于接收数据。发送方可以通过滑动窗口的大小来确定应该发送多少字节的数据。当滑动窗口为0时,发送方一般不能再发送数据报,但有两种情况除外,一种情况是可以发送紧急数据,例如,允许用户终止在远端机上的运行进程。另一种情况是发送方可以发送一个1字节的数据报来通知接收方重新声明它希望接收的下一字节及发送方的滑动窗口大小。

33. 怎么做弹性扩缩容,原理是什么?

弹性伸缩(Auto Scaling)根据您的业务需求和伸缩策略,为您自动调整计算资源。您可设置定时、周期或监控策略,恰到好处地增加或减少CVM实例,并完成实例配置,保证业务平稳健康运行。在需求高峰期时,弹性伸缩自动增加CVM实例的数量,以保证性能不受影响;当需求较低时,则会减少CVM实例数量以降低成本。弹性伸缩既适合需求稳定的应用程序,同时也适合每天、每周、每月使用量不停波动的应用程序。

34. 让你设计一个web框架,你要怎么设计,说一下步骤.

35. 说一下中间件原理.

中间件(middleware)是基础软件的一大类,属于可复用软件的范畴。中间件处于操作系统软件与用户的应用软件的中间。中间件在操作系统、网络和数据库之上,应用软件的下层,总的作用是为处于自己上层的应用软件提供运行与开发的环境,帮助用户灵活、高效地开发和集成复杂的应用软件  IDC的定义是:中间件是一种独立的系统软件或服务程序,分布式应用软件借助这种软件在不同的技术之间共享资源,中间件位于客户机服务器的操作系统之上,管理计算资源和网络通信。

中间件解决的问题是:

在中间件产生以前,应用软件直接使用操作系统、网络协议和数据库等开发,这些都是计算机最底层的东西,越底层越复杂,开发者不得不面临许多很棘手的问题,如操作系统的多样性,繁杂的网络程序设计、管理,复杂多变的网络环境,数据分散处理带来的不一致性问题、性能和效率、安全,等等。这些与用户的业务没有直接关系,但又必须解决,耗费了大量有限的时间和精力。于是,有人提出能不能将应用软件所要面临的共性问题进行提炼、抽象,在操作系统之上再形成一个可复用的部分,供成千上万的应用软件重复使用。这一技术思想最终构成了中间件这类的软件。中间件屏蔽了底层操作系统的复杂性,使程序开发人员面对一个简单而统一的开发环境,减少程序设计的复杂性,将注意力集中在自己的业务上,不必再为程序在不同系统软件上的移植而重复工作,从而大大减少了技术上的负担。

36. 怎么设计orm,让你写,你会怎么写?

对象关系映射(Object Relational Mapping,简称ORM),是一种程序技术,用于实现面向对象编程语言里不同类型系统的数据之间的转换。从效果上说,它其实是创建了一个可在编程语言里使用的--“虚拟对象数据库”。

通常来讲,ORM就是将数据库中的表映射成一个对象实体A,对A进行操作,就相对对数据库进行操作,完成这个过程,其实只要你好好想想你是怎么操作数据库的,然后将类似的行为换成对象即可。

要设计一个ORM,我们需要几步操作:

  1. 先准备好一个对象A和数据库中某张表对应T(A->T)

  2. 我们知道当你创建一个表时,一般使用create命令如下:

CREATE TABLE database_name.table_name( 
column1 datatype PRIMARY KEY(one or more columns), 
column2 datatype, 
column3 datatype, 
….. 
columnN datatype, 
);

从这里我们可以看就对应着表名,表字段名,字段类型,是否是主键等,此时我们需要如何根据A中成员变量,而知道T的这些内容?如果你能根据A能够转化成T,那此时你就已经将A映射到了T了.

  1. 映射过程完成后,接下来就是要具备表的四种操作:增,删,改,查.
SELECT * FROM T WHERE field1 >= ? OR field2 >= ?;

这个过程其实是构建where语句的过程,我们可以根据一些条件,构建where语句,然后映射到成一个sql语句,根据sql语句我们就可以查询到一组符合条件的数据(cursor),然后就是将cursor数据转化成A.

现在总结下第三步操作的两个过程:condition ->sql语句;cursor reslut->A

完成这三部,基本上就完成了一个ORM的设计,如果后面需要对性能,细节进行优化,就可以慢慢来。毕竟主功能已具备。

注意:如果想线程安全进行数据库操作可以考虑  db.enableWriteAheadLogging();

设计orm过程就是:

  • 根据A得到 T;
  • 根据condition构建where,拼接成sql;
  • 根据sql从T中查出cursors;
  • cursor转化成A.

37. 用过原生的http包吗?

Golang中http包中处理 HTTP 请求主要跟两个东西相关:ServeMux 和 Handler。

ServeMux 本质上是一个 HTTP 请求路由器(或者叫多路复用器,Multiplexor)。它把收到的请求与一组预先定义的 URL 路径列表做对比,然后在匹配到路径的时候调用关联的处理器(Handler)。

处理器(Handler)负责输出HTTP响应的头和正文。任何满足了http.Handler接口的对象都可作为一个处理器。通俗的说,对象只要有个如下签名的ServeHTTP方法即可:

ServeHTTP(http.ResponseWriter, *http.Request)

Go 语言的 HTTP 包自带了几个函数用作常用处理器,比如FileServer,NotFoundHandler 和 RedirectHandler。

应用示例:

package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()

	rh := http.RedirectHandler("http://www.baidu.com", 307)
	mux.Handle("/foo", rh)

	log.Println("Listening...")
	http.ListenAndServe(":3000", mux)
}

在这个应用示例中,首先在 main 函数中我们只用了 http.NewServeMux 函数来创建一个空的 ServeMux。 然后我们使用 http.RedirectHandler 函数创建了一个新的处理器,这个处理器会对收到的所有请求,都执行307重定向操作到 http://www.baidu.com。 接下来我们使用 ServeMux.Handle 函数将处理器注册到新创建的 ServeMux,所以它在 URL 路径/foo 上收到所有的请求都交给这个处理器。 最后我们创建了一个新的服务器,并通过 http.ListenAndServe 函数监听所有进入的请求,通过传递刚才创建的 ServeMux来为请求去匹配对应处理器。 在浏览器中访问 http://localhost:3000/foo,你应该能发现请求已经成功的重定向了。

此刻你应该能注意到一些有意思的事情:ListenAndServer 的函数签名是 ListenAndServe(addr string, handler Handler) ,但是第二个参数我们传递的是个 ServeMux。

通过这个例子我们就可以知道,net/http包在编写golang web应用中有很重要的作用,它主要提供了基于HTTP协议进行工作的client实现和server实现,可用于编写HTTP服务端和客户端。

38. 一个非常大的数组,让其中两个数想加等于1000怎么算?

39. 各个系统出问题怎么监控报警.

可以使用prometheus搭配kong网关,监控各个系统的接口转发处,进行处理报警,然后上报之后,设置阈值,报警和自恢复系统设计。

40. 常用测试工具,压测工具,方法?

goconvey,vegeta

41. 复杂的单元测试怎么测试,比如有外部接口mysql接口的情况?

42. redis集群,哨兵,持久化,事务.

43. mysql和redis区别是什么?

  • mysql和redis的数据库类型

mysql是关系型数据库,主要用于存放持久化数据,将数据存储在硬盘中,读取速度较慢。

redis是NOSQL,即非关系型数据库,也是缓存数据库,即将数据存储在缓存中,缓存的读取速度快,能够大大的提高运行效率,但是保存时间有限。

  • mysql的运行机制

mysql作为持久化存储的关系型数据库,相对薄弱的地方在于每次请求访问数据库时,都存在着I/O操作,如果反复频繁的访问数据库。第一:会在反复链接数据库上花费大量时间,从而导致运行效率过慢;第二:反复的访问数据库也会导致数据库的负载过高,那么此时缓存的概念就衍生了出来。

  • 缓存

缓存就是数据交换的缓冲区(cache),当浏览器执行请求时,首先会对在缓存中进行查找,如果存在,就获取;否则就访问数据库。

缓存的好处就是读取速度快。

  • redis数据库

redis数据库就是一款缓存数据库,用于存储使用频繁的数据,这样减少访问数据库的次数,提高运行效率。

  • redis和mysql的区别总结

(1)类型上

从类型上来说,mysql是关系型数据库,redis是缓存数据库

(2)作用上

mysql用于持久化的存储数据到硬盘,功能强大,但是速度较慢

redis用于存储使用较为频繁的数据到缓存中,读取速度快

(3)需求上

mysql和redis因为需求的不同,一般都是配合使用。

44. 高可用软件是什么?

  • Heartbeat

Heartbeat是一个比较老牌的集群管理软件,最新版本是V3.0, 也称为Heartbeat 3.通过Heartbeat,可以实现对服务器资源(ip以及程序服务等资源)的监控和管理,并在出现故障的情况下,将资源集合从一台已经故障的计算机快速转移到另一台正常运转的机器上继续提供服务。

  • Keepalived

Keepalived也是一款高可用集群管理软件,其基本功能与Heartbeat非常类似。

Keepalived它的功能主要包括两方面:

1)通过IP漂移,实现服务的高可用:服务器集群共享一个虚拟IP,同一时间只有一个服务器占有虚拟IP并对外提供服务,若该服务器不可用,则虚拟IP漂移至另一台服务器并对外提供服务;

2)对LVS应用服务层的应用服务器集群进行状态监控:若应用服务器不可用,则keepalived将其从集群中摘除,若应用服务器恢复,则keepalived将其重新加入集群中。

Keepalived可以单独使用,即通过IP漂移实现服务的高可用,也可以结合LVS使用,即一方面通过IP漂移实现LVS负载均衡层的高可用,另一方面实现LVS应用服务层的状态监控。

45. 怎么搞一个并发服务程序?

46. 讲解一下你做过的项目,然后找问题问实现细节。

47. mysql事务说下

事务是一组原子性的sql命令或者说是一个独立的工作单元,如果数据库引擎能够成功的对数据库应用该组的全部sql语句,那么就执行该组命令如果其中有任何一条语句因为崩溃或者其它原因无法执行,那么该组中所有的sql语句都不会执行,如果没有显示启动事务,数据库会根据autocommit的值.默认每条sql操作都会自动提交。

  • 原子性

一个事务中的所有操作,要么都完成,要么都不执行.对于一个事务来说,不可能只执行其中的一部分。

  • 一致性

在事务开始和完成时,数据都必须保持一致状态。这意味着所有相关的数据规则都必须应用于事务的修改,以保持数据的完整性;事务结束时,所有的内部数据结构(如B树索引或双向链表)也都必须是正确的。 以转账为例子,A向B转账,假设转账之前这两个用户的钱加起来总共是100,那么A向B转账之后,不管这两个账户怎么转,A用户的钱和B用户的钱加起来的总额还是100,这个就是事务的一致性。

数据库一致性是指事务必须使数据库从一个一致的状态变到另外一个一致的状态,也就是执行事务之前和之后的状态都必须处于一致的状态。

在事务T开始时,此时数据库有一种状态,这个状态是所有的MySQL对象处于一致的状态,例如数据库完整性约束正确,日志状态一致等,当事务T提交后,这时数据库又有了一个新的状态,不同的数据,不同的索引,不同的日志等,但此时,约束,数据,索引,日志等MySQL各种对象还是要保持一致性(正确性)。 这就是 从一个一致性的状态,变到另一个一致性的状态。也就是事务执行后,并没有破坏数据库的完整性约束(一切都是对的)。
  • 隔离性

隔离性是指当多个用户并发访问数据库时,比如操作同一张表时,数据库为每一个用户开启的事务,不能被其他事务的操作所干扰,多个并发事务之间要相互隔离。

对于任意两个并发的事务T1和T2,在事务T1看来,T2要么在T1开始之前就已经结束,要么在T1结束之后才开始,这样每个事务都感觉不到有其他事务在并发地执行。
  • 持久性

持久性是指一个事务一旦被提交了,那么对于数据库中的数据改变就是永久性的,即便是在数据库系统遭遇到故障的情况下也不会丢失提交事务的操作。

我们在使用连接池操作数据库时,在提交事务方法后,提示用户事务操作完成,当我们程序执行完成直到看到提示后,就可以认定事务以及正确提交,即使这时候数据库出现了问题,也必须要将我们的事务完全执行完成,否则就会造成我们看到提示事务处理完毕,但是数据库因为故障而没有执行事务的重大错误。

MySQL 默认采用自动提交模式。也就是说,如果不显式使用 START TRANSACTION 语句来开始一个事务,那么每个查询都会被当做一个事务自动提交。

这四个特性是:

  • 只有满足一致性,事务的执行结果才是正确的。
  • 在无并发的情况下,事务串行执行,隔离性一定能够满足。此时要只要能满足原子性,就一定能满足一致性。
  • 在并发的情况下,多个事务并发执行,事务不仅要满足原子性,还需要满足隔离性,才能满足一致性。
  • 事务满足持久化是为了能应对数据库奔溃的情况。

48. 怎么做一个自动化配置平台系统?

49. grpc遵循什么协议?

grpc是由Google主导开发的RPC框架,使用HTTP/2协议并用ProtoBuf作为序列化工具。gRPC是动态代理的模式实现的,客户端应用可以像调用本地对象一样直接调用另一台不同的机器上服务端应用的方法。和传统的REST不同的是gRPC使用了静态路径,从而提高性能,另外开发者不用了解各种底层网络协议,不用去拼REST风格的动态URL,用一些格式化的错误码代替了HTTP的状态码,不用管各种的HTTP状态码,开发者开发效率比较高。客户端可以充分利用高级流和链接功能,从而有助于节省带宽、降低的TCP链接次数、节省CPU使用.

50. grpc内部原理是什么?

gRPC 是一个高性能、开源和通用的 RPC 框架,面向移动和 HTTP/2 设计。gRPC 默认使用 protocol buffers,这是 Google 开源的一套成熟的结构数据序列化机制(当然也可以使用其他数据格式如 JSON).

51. http2的特点是什么,与http1.1的对比。

HTTP1.1HTTP2QUIC
持久连接二进制分帧基于UDP的多路传输(单连接下)
请求管道化多路复用(或连接共享)极低的等待时延(相比于TCP的三次握手)
增加缓存处理(新的字段如cache-control)头部压缩QUIC为 传输层 协议 ,成为更多应用层的高性能选择
增加Host字段、支持断点传输等(把文件分成几部分)服务器推送 

52. Go的调度原理.

调度器的三个抽象概念:G、M、P

  • G:代表一个 goroutine,每个 goroutine 都有自己独立的栈存放当前的运行内存及状态。可以把一个G当做一个任务。

  • M: 代表内核线程(Pthread),它本身就与一个内核线程进行绑定,goroutine 运行在M上。

  • P:代表一个处理器,可以认为一个“有运行任务”的P占了一个CPU线程的资源,且只要处于调度的时候就有P。

内核线程和 CPU 线程的区别,在系统里可以有上万个内核线程,但 CPU 线程并没有那么多,CPU 线程也就是 Top 命令里看到的 CPU0、CPU1、CPU2......的数量。

 

图1、图2代表2个有运行任务时的状态。M 与一个内核线程绑定,可运行的 goroutine 列表存放到P里面,然后占用了一个CPU线程来运行。 图3代表没有运行任务时的状态,M 依然与一个内核线程绑定,由于没有运行任务因此不占用 CPU 线程,同时也不占用P。

进程启动时都做了什么?下面我们通过汇编plan9看下:

  • go进程的启动

进程的本质是代码区的指令不断执行,驱使动态数据区和静态数据区产生数据变化。

golang进程怎么启动的。熟悉c的同学应该知道,c语言的main函数是程序的入口函数,在golang中main包中的main函数并不是入口函数, 入口函数是在asm_amd64.s中定义的,而main包中的main函数是由runtime main函数启动的。但是这里我们不详细探讨系统是怎么到程序入口函数的, 我们只需要明白,go程序启动后,会调用 runtime·rt0_go 来执行程序的初始化和启动调度系统。runtime·rt0_go 很重要,如果要是自己看runtime的源码, 可以从这个函数看起。

// runtime·rt0_go

// 程序刚启动的时候必定有一个线程启动(主线程)
// 将当前的栈和资源保存在g0
// 将该线程保存在m0
// tls: Thread Local Storage
// set the per-goroutine and per-mach "registers"
get_tls(BX)
ok:
        // set the per-goroutine and per-mach "registers"
        get_tls(BX)  // 将 g0 放到 tls(thread local storage)里
        LEAQ    runtime·g0(SB), CX
        MOVQ    CX, g(BX)
        LEAQ    runtime·m0(SB), AX

        // save m->g0 = g0  // 将全局M0与全局G0绑定
        MOVQ    CX, m_g0(AX)
        // save m0 to g0->m
        MOVQ    AX, g_m(CX)

        CLD                             // convention is D is always left cleared
        CALL    runtime·check(SB)

        MOVL    16(SP), AX              // copy argc
        MOVL    AX, 0(SP)
        MOVQ    24(SP), AX              // copy argv
        MOVQ    AX, 8(SP)
        CALL    runtime·args(SB) // 解析命令行参数
        CALL    runtime·osinit(SB) // 只初始化了CPU核数
        CALL    runtime·schedinit(SB) // 内存分配器、栈、P、GC回收器等初始化

        // create a new goroutine to start program
        MOVQ    $runtime·mainPC(SB), AX         // 
        PUSHQ   AX
        PUSHQ   $0                      // arg size
        CALL    runtime·newproc(SB) // 创建一个新的G来启动runtime.main
        POPQ    AX
        POPQ    AX

        // start this M
        CALL    runtime·mstart(SB) // 启动M0,开始等待空闲G,正式进入调度循环

        MOVL    $0xf1, 0xf1  // crash
        RET

在启动过程里主要做了这三个事情(这里只跟调度相关的):

  • 初始化固定数量的P.
  • 创建一个新的G来启动 runtime.main, 也就是 runtime 下的 main 方法.
  • 创建全局 M0、全局 G0,启动 M0 进入第一个调度循环.

这里注意下:

M0 是什么?程序里会启动多个 M,第一个启动的叫 M0。G0 是什么?
G 分三种,第一种是执行用户任务的叫做 G,第二种执行 runtime 下调度工作的叫G0,每个M都绑定一个G0。第三种则是启动 runtime.main 用到的G。写程序接触到的基本都是第一种.

我们按照顺序看是怎么完成上面三个事情的。

runtime.osinit(SB)方法针对系统环境的初始化.

这里实质只做了一件事情,就是获取 CPU 的线程数,也就是 Top 命令里看到的 CPU0、CPU1、CPU2......的数量。

// runtime/os_linux.go

func osinit() {
  ncpu = getproccount()
}

runtime.schedinit(SB)调度相关的一些初始化.

// runtime/proc.go

// 设置最大M数量
sched.maxmcount = 10000

// 初始化当前M,即全局M0
mcommoninit(_g_.m)

// 查看应该启动的P数量,默认为cpu core数.
// 如果设置了环境变量GOMAXPROCS则以环境变量为准,最大不得超过_MaxGomaxprocs(1024)个
procs := ncpu
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
  procs = n
}
if procs > _MaxGomaxprocs {
  procs = _MaxGomaxprocs
}
// 调整P数量,此时由于是初始化阶段,所以P都是新建的
if procresize(procs) != nil {
  throw("unknown runnable goroutine during bootstrap")
}

这里 sched.maxmcount 设置了M最大的数量,而M代表的是系统内核线程,因此可以认为一个进程最大只能启动10000个系统线程。

procresize 初始化P的数量,procs 参数为初始化的数量,而在初始化之前先做数量的判断,默认是 ncpu(与CPU核数相等)。也可以通过环境变量 GOMAXPROCS 来控制P的数量。_MaxGomaxprocs 控制了最大的P数量只能是1024。

通常在进程初始化的时候经常用到 runtime.GOMAXPROCS() 方法,其实也是调用的 procresize 方法重新设置了最大 CPU 使用数量。

runtime·mainPC(SB)启动监控任务.

// runtime/proc.go

// The main goroutine.
func main() {
  ......
  
  // 启动后台监控
  systemstack(func() {
    newm(sysmon, nil)
  })

  ......
}

在 runtime 下会启动一个全程运行的监控任务,该任务用于标记抢占执行过长时间的G,以及检测 epoll 里面是否有可执行的G。下面会详细说到。

最后 runtime·mstart(SB)启动调度循环.

前面都是各种初始化操作,在这里开启了调度器的第一个调度循环。(这里启动的M就是M0)

下面来围绕G、M、P三个概念介绍 Goroutine 调度循环的运作流程。

 

图1代表M启动的过程,把M跟一个P绑定在一起。在程序初始化的过程中,到在进程启动的最后一步启动了第一个M(即M0),这个M从全局的空闲P列表里拿到一个P,然后与其绑定。而P里面有2个管理G的链表(runq 存储等待运行的G列表,gfree 存储空闲的G列表),M启动后等待可执行的G。

图2代表创建G的过程。创建完一个G先扔到当前P的 runq 待运行队列里。在图3的执行过程里,M从绑定的P的 runq 列表里获取一个G来执行。当执行完成后,图4的流程里把G仍到 gfree 队列里。注意此时G并没有销毁(只重置了G的栈以及状态),当再次创建G的时候优先从 gfree 列表里获取,这样就起到了复用G的作用,避免反复与系统交互创建内存。

M即启动后处于一个自循环状态,执行完一个G之后继续执行下一个G,反复上面的图2~图4过程。当第一个M正在繁忙而又有新的G需要执行时,会再开启一个M来执行。

接着我们详细看下调度循环的实现。

调度器如何开启调度循环?

先看一下M的启动过程(M0启动是个特殊的启动过程,也是第一个启动的M,由汇编实现的初始化后启动,而后续的M创建以及启动则是Go代码实现)。

// runtime/proc.go

func startm(_p_ *p, spinning bool) {
  lock(&sched.lock)
  if _p_ == nil {
    // 从空闲P里获取一个
    _p_ = pidleget()
    
    ......
  }
  // 获取一个空闲的m
  mp := mget()
  unlock(&sched.lock)
  // 如果没有空闲M,则new一个
  if mp == nil {
    var fn func()
    if spinning {
      // The caller incremented nmspinning, so set m.spinning in the new M.
      fn = mspinning
    }
    newm(fn, _p_)
    return
  }
  
  ......
  
  // 唤醒M
  notewakeup(&mp.park)
}

func newm(fn func(), _p_ *p) {
  // 创建一个M对象,且与P关联
  mp := allocm(_p_, fn)
  // 暂存P
  mp.nextp.set(_p_)
  mp.sigmask = initSigmask
  
  ......
  
  execLock.rlock() // Prevent process clone.
  // 创建系统内核线程
  newosproc(mp, unsafe.Pointer(mp.g0.stack.hi))
  execLock.runlock()
}

// runtime/os_linux.go
func newosproc(mp *m, stk unsafe.Pointer) {
  // Disable signals during clone, so that the new thread starts
  // with signals disabled. It will enable them in minit.
  var oset sigset
  sigprocmask(_SIG_SETMASK, &sigset_all, &oset)
  ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(funcPC(mstart)))
  sigprocmask(_SIG_SETMASK, &oset, nil)
}

func allocm(_p_ *p, fn func()) *m {
  ......
  
  mp := new(m)
  mp.mstartfn = fn // 设置启动函数
  mcommoninit(mp)  // 初始化m

  // 创建g0
  // In case of cgo or Solaris, pthread_create will make us a stack.
  // Windows and Plan 9 will layout sched stack on OS stack.
  if iscgo || GOOS == "solaris" || GOOS == "windows" || GOOS == "plan9" {
    mp.g0 = malg(-1)
  } else {
    mp.g0 = malg(8192 * sys.StackGuardMultiplier)
  }
  // 把新创建的g0与M做关联
  mp.g0.m = mp

  ......
  
  return mp
}

func mstart() {
  ......
  
  mstart1()
}

func mstart1() {

  ......
  
  // 进入调度循环(阻塞不返回)
  schedule()
}

非M0的启动首先从 startm 方法开始启动,要进行调度工作必须有调度处理器P,因此先从空闲的P链表里获取一个P,在 newm 方法创建一个M与P绑定。

newm 方法中通过 newosproc 新建一个内核线程,并把内核线程与M以及 mstart 方法进行关联,这样内核线程执行时就可以找到M并且找到启动调度循环的方法。最后 schedule 启动调度循环.

allocm 方法中创建M的同时创建了一个G与自己关联,这个G就是我们在上面说到的g0。为什么M要关联一个g0?因为 runtime 下执行一个G也需要用到栈空间来完成调度工作,而拥有执行栈的地方只有G,因此需要为每个执行线程里配置一个g0。

调度器如何进行调度循环?

调用 schedule 进入调度器的调度循环后,在这个方法里永远不再返回。下面看下实现。

// runtime/proc.go

func schedule() {
  _g_ := getg()

  // 进入gc MarkWorker 工作模式
  if gp == nil && gcBlackenEnabled != 0 {
    gp = gcController.findRunnableGCWorker(_g_.m.p.ptr())
  }
  if gp == nil {
    // Check the global runnable queue once in a while to ensure fairness.
    // Otherwise two goroutines can completely occupy the local runqueue
    // by constantly respawning each other.
    // 每处理n个任务就去全局队列获取G任务,确保公平
    if _g_.m.p.ptr().schedtick%61 == 0 && sched.runqsize > 0 {
      lock(&sched.lock)
      gp = globrunqget(_g_.m.p.ptr(), 1)
      unlock(&sched.lock)
    }
  }
  // 从P本地获取
  if gp == nil {
    gp, inheritTime = runqget(_g_.m.p.ptr())
    if gp != nil && _g_.m.spinning {
      throw("schedule: spinning with local work")
    }
  }
  // 从其它地方获取G,如果获取不到则沉睡M,并且阻塞在这里,直到M被再次使用
  if gp == nil {
    gp, inheritTime = findrunnable() // blocks until work is available
  }

  ......
  
  // 执行找到的G
  execute(gp, inheritTime)
}

// 从P本地获取一个可运行的G
func runqget(_p_ *p) (gp *g, inheritTime bool) {
  // If there's a runnext, it's the next G to run.
  // 优先从runnext里获取一个G,如果没有则从runq里获取
  for {
    next := _p_.runnext
    if next == 0 {
      break
    }
    if _p_.runnext.cas(next, 0) {
      return next.ptr(), true
    }
  }

  // 从队头获取
  for {
    h := atomic.Load(&_p_.runqhead) // load-acquire, synchronize with other consumers
    t := _p_.runqtail
    if t == h {
      return nil, false
    }
    gp := _p_.runq[h%uint32(len(_p_.runq))].ptr()
    if atomic.Cas(&_p_.runqhead, h, h+1) { // cas-release, commits consume
      return gp, false
    }
  }
}

// 从其它地方获取G
func findrunnable() (gp *g, inheritTime bool) {
  ......

  // 从本地队列获取
  if gp, inheritTime := runqget(_p_); gp != nil {
    return gp, inheritTime
  }

  // 全局队列获取
  if sched.runqsize != 0 {
    lock(&sched.lock)
    gp := globrunqget(_p_, 0)
    unlock(&sched.lock)
    if gp != nil {
      return gp, false
    }
  }
  
  // 从epoll里取
  if netpollinited() && sched.lastpoll != 0 {
    if gp := netpoll(false); gp != nil { // non-blocking
      ......
      
      return gp, false
    }
  }
  
  ......
  
  // 尝试4次从别的P偷
  for i := 0; i < 4; i++ {
    for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
      if sched.gcwaiting != 0 {
        goto top
      }
      stealRunNextG := i > 2 // first look for ready queues with more than 1 g
      // 在这里开始针对P进行偷取操作
      if gp := runqsteal(_p_, allp[enum.position()], stealRunNextG); gp != nil {
        return gp, false
      }
    }
  }
}

// 尝试从全局runq中获取G
// 在"sched.runqsize/gomaxprocs + 1"、"max"、"len(_p_.runq))/2"三个数字中取最小的数字作为获取的G数量
func globrunqget(_p_ *p, max int32) *g {
  if sched.runqsize == 0 {
    return nil
  }

  n := sched.runqsize/gomaxprocs + 1
  if n > sched.runqsize {
    n = sched.runqsize
  }
  if max > 0 && n > max {
    n = max
  }
  if n > int32(len(_p_.runq))/2 {
    n = int32(len(_p_.runq)) / 2
  }

  sched.runqsize -= n
  if sched.runqsize == 0 {
    sched.runqtail = 0
  }

  gp := sched.runqhead.ptr()
  sched.runqhead = gp.schedlink
  n--
  for ; n > 0; n-- {
    gp1 := sched.runqhead.ptr()
    sched.runqhead = gp1.schedlink
    runqput(_p_, gp1, false) // 放到本地P里
  }
  return gp
}

schedule 中首先尝试从P本地队列中获取(runqget)一个可执行的G,如果没有则从其它地方获取(findrunnable),最终通过 execute 方法执行G。 runqget 先通过 runnext 拿到待运行G,没有的话,再从 runq 里面取。

findrunnable 从全局队列、epoll、别的P里获取。(后面会扩展分析实现) 在调度的开头出还做了一个小优化:每处理一些任务之后,就优先从全局队列里获取任务,以保障公平性,防止由于每个P里的G过多,而全局队列里的任务一直得不到执行机会。

这里用到了一个关键方法getg(),runtime 的代码里大量使用该方法,它由汇编实现,该方法就是获取当前运行的G。

  • 多个线程下如何调度

每个P里面的G执行时间是不可控的,如果多个P同时在执行,会不会出现有的P里面的G执行不完,有的P里面几乎没有G可执行呢?

这个问题就要从M的自循环过程中如何获取G、归还G的行为说起了.

 

通过图中可以看出有两种途径:

  1. 借助全局队列 sched.runq 作为中介,本地P里的G太多的话就放全局里,G太少的话就从全局取。
  2. 全局列表里没有的话直接从P1里偷取(steal)。(更多M在执行的话,同样的原理,这里就只拿2个来举例)

第1种途径实现如下:

// runtime/proc.go

func runqput(_p_ *p, gp *g, next bool) {
  if randomizeScheduler && next && fastrand()%2 == 0 {
    next = false
  }

  // 尝试把G添加到P的runnext节点,这里确保runnext只有一个G,如果之前已经有一个G则踢出来放到runq里
  if next {
  retryNext:
    oldnext := _p_.runnext
    if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
      goto retryNext
    }
    if oldnext == 0 {
      return
    }
    // 把老的g踢出来,在下面放到runq里
    gp = oldnext.ptr()
  }

retry:
  // 如果_p_.runq队列不满,则放到队尾就结束了。
  // 试想如果不放到队尾而放到队头里会怎样?如果频繁的创建G则可能后面的G总是不被执行,对后面的G不公平
  h := atomic.Load(&_p_.runqhead) // load-acquire, synchronize with consumers
  t := _p_.runqtail
  if t-h < uint32(len(_p_.runq)) {
    _p_.runq[t%uint32(len(_p_.runq))].set(gp)
    atomic.Store(&_p_.runqtail, t+1) // store-release, makes the item available for consumption
    return
  }
  //如果队列满了,尝试把G和当前P里的一部分runq放到全局队列
  //因为操作全局需要加锁,所以名字里带个slow
  if runqputslow(_p_, gp, h, t) {
    return
  }
  // the queue is not full, now the put above must succeed
  goto retry
}

func runqputslow(_p_ *p, gp *g, h, t uint32) bool {
  var batch [len(_p_.runq)/2 + 1]*g

  // First, grab a batch from local queue.
  n := t - h
  n = n / 2
  if n != uint32(len(_p_.runq)/2) {
    throw("runqputslow: queue is not full")
  }
  // 从runq头部开始取出一半的runq放到临时变量batch里
  for i := uint32(0); i < n; i++ {
    batch[i] = _p_.runq[(h+i)%uint32(len(_p_.runq))].ptr()
  }
  if !atomic.Cas(&_p_.runqhead, h, h+n) { // cas-release, commits consume
    return false
  }
  // 把要put的g也放进batch去
  batch[n] = gp

  if randomizeScheduler {
    for i := uint32(1); i <= n; i++ {
      j := fastrandn(i + 1)
      batch[i], batch[j] = batch[j], batch[i]
    }
  }

  // 把取出来的一半runq组成链表
  for i := uint32(0); i < n; i++ {
    batch[i].schedlink.set(batch[i+1])
  }

  // 将一半的runq放到global队列里,一次多转移一些省得转移频繁
  lock(&sched.lock)
  globrunqputbatch(batch[0], batch[n], int32(n+1))
  unlock(&sched.lock)
  return true
}

func globrunqputbatch(ghead *g, gtail *g, n int32) {
  gtail.schedlink = 0
  if sched.runqtail != 0 {
    sched.runqtail.ptr().schedlink.set(ghead)
  } else {
    sched.runqhead.set(ghead)
  }
  sched.runqtail.set(gtail)
  sched.runqsize += n
}

runqput 方法归还执行完的G,runq 定义是 runq [256]guintptr,有固定的长度,因此当前P里的待运行G超过256的时候说明过多了,则执行 runqputslow 方法把一半G扔给全局G链表,globrunqputbatch 连接全局链表的头尾指针。

但可能别的P里面并没有超过256,就不会放到全局G链表里,甚至可能一直维持在不到256个。这就借助第2个途径了.

第2种途径实现如下:

// runtime/proc.go

// 从其它地方获取G
func findrunnable() (gp *g, inheritTime bool) {
  ......
  
  // 尝试4次从别的P偷
  for i := 0; i < 4; i++ {
    for enum := stealOrder.start(fastrand()); !enum.done(); enum.next() {
      if sched.gcwaiting != 0 {
        goto top
      }
      stealRunNextG := i > 2 // first look for ready queues with more than 1 g
      // 在这里开始针对P进行偷取操作
      if gp := runqsteal(_p_, allp[enum.position()], stealRunNextG); gp != nil {
        return gp, false
      }
    }
  }
}

从别的P里面"偷取"一些G过来执行了。runqsteal 方法实现了"偷取"操作。

// runtime/proc.go

// 偷取P2一半到本地运行队列,失败则返回nil
func runqsteal(_p_, p2 *p, stealRunNextG bool) *g {
  t := _p_.runqtail
  n := runqgrab(p2, &_p_.runq, t, stealRunNextG)
  if n == 0 {
    return nil
  }
  n--
  // 返回尾部的一个G
  gp := _p_.runq[(t+n)%uint32(len(_p_.runq))].ptr()
  if n == 0 {
    return gp
  }
  h := atomic.Load(&_p_.runqhead) // load-acquire, synchronize with consumers
  if t-h+n >= uint32(len(_p_.runq)) {
    throw("runqsteal: runq overflow")
  }
  atomic.Store(&_p_.runqtail, t+n) // store-release, makes the item available for consumption
  return gp
}

// 从P里获取一半的G,放到batch里
func runqgrab(_p_ *p, batch *[256]guintptr, batchHead uint32, stealRunNextG bool) uint32 {
  for {
    // 计算一半的数量
    h := atomic.Load(&_p_.runqhead) // load-acquire, synchronize with other consumers
    t := atomic.Load(&_p_.runqtail) // load-acquire, synchronize with the producer
    n := t - h
    n = n - n/2
    
    ......
    
    // 将偷到的任务转移到本地P队列里
    for i := uint32(0); i < n; i++ {
      g := _p_.runq[(h+i)%uint32(len(_p_.runq))]
      batch[(batchHead+i)%uint32(len(batch))] = g
    }
    if atomic.Cas(&_p_.runqhead, h, h+n) { // cas-release, commits consume
      return n
    }
  }
}

由此可以看出从别的P里面偷(steal)了一半,这样就足够运行了。有了“偷取”操作也就充分利用了多线程的资源。

53. go struct能不能比较

  • 相同struct类型的可以比较

  • 不同struct类型的不可以比较,编译都不过,类型不匹配

package main
import "fmt"
func main() {
    type A struct {
        a int
    }
    type B struct {
        a int
    }
    a := A{1}
    //b := A{1}
    b := B{1}
    if a == b {
        fmt.Println("a == b")
    }else{
        fmt.Println("a != b")
    }
} 
// output
// command-line-arguments [command-line-arguments.test]
// ./.go:14:7: invalid operation: a == b (mismatched types A and B) 

54. go defer(for defer)

什么是 defer?如何理解 defer 关键字?Go 中使用 defer 的一些坑。

defer 意为延迟,在 golang 中用于延迟执行一个函数。它可以帮助我们处理容易忽略的问题,如资源释放、连接关闭等。但在实际使用过程中,有一些需要注意的地方.

  1. 若函数中有多个 defer,其执行顺序为 先进后出,可以理解为栈。
package main

import "fmt"

func main() {
  for i := 0; i < 5; i++ {
    defer fmt.Println(i)
  }
}

运行:

4
3
2
1
0
  1. return 会做什么呢?

Go 的函数返回值是通过堆栈返回的, return 语句不是原子操作,而是被拆成了两步,

  • 给返回值赋值 (rval)
  • 调用 defer 表达式
  • 返回给调用函数(ret)
package main

import "fmt"

func main() {
    fmt.Println(increase(1))
}

func increase(d int) (ret int) {
  defer func() {
    ret++
  }()

  return d
}

运行输出:

2
  1. 若 defer 表达式有返回值,将会被丢弃。

闭包与匿名函数.

  • 匿名函数:没有函数名的函数。
  • 闭包:可以使用另外一个函数作用域中的变量的函数。

在实际开发中,defer 的使用经常伴随着闭包与匿名函数的使用。

package main

import "fmt"

func main() {
    for i := 0; i < 5; i++ {
        defer func() {
            fmt.Println(i)
        }()
    }
}

运行输出:

5
5
5
5
5

之所以这样是因为,defer 表达式中的 i 是对 for 循环中 i 的引用。到最后,i 加到 5,故最后全部打印 5。

如果将 i 作为参数传入 defer 表达式中,在传入最初就会进行求值保存,只是没有执行延迟函数而已。

应用示例:

func f1() (result int) {
    defer func() {
        result++
    }()
    return 0
}
func f2() (r int) {
    t := 5
   defer func() {
	t = t + 5
   }()
   return t
}
func f3() (r int) {
    defer func(r int) {
        r = r + 5
    }(r)
    return 1
}
type Test struct {
    Max int
}

func (t *Test) Println() {
    fmt.Println(t.Max)
}

func deferExec(f func()) {
    f()
}

func call() {
    var t *Test
    defer deferExec(t.Println)

    t = new(Test)
}

有没有得出结果?例1的答案不是 0,例2的答案不是 10,例3的答案也不是 6。

f1: 比较简单,参考结论2,将 0 赋给 result,defer 延迟函数修改 result,最后返回给调用函数。正确答案是 1。

f2: defer 是在 t 赋值给 r 之后执行的,而 defer 延迟函数只改变了 t 的值,r 不变。正确答案 5。

f3: 这里将 r 作为参数传入了 defer 表达式。故 func (r int) 中的 r 非 func f() (r int) 中的 r,只是参数命名相同而已。正确答案 1。

f4: 这里将发生 panic。将方法传给 deferExec,实际上在传的过程中对方法求了值。而此时的 t 任然为 nil。

55. select可以用于什么?

Golang 的 select 机制可以理解为是在语言层面实现了和 select, poll, epoll 相似的功能:监听多个描述符的读/写等事件,一旦某个描述符就绪(一般是读或者写事件发生了),就能够将发生的事件通知给关心的应用程序去处理该事件。 golang 的 select 机制是,监听多个channel,每一个 case 是一个事件,可以是读事件也可以是写事件,随机选择一个执行,可以设置default,它的作用是:当监听的多个事件都阻塞住会执行default的逻辑。

select的源码在 (runtime/select.go)[https://github.com/golang/go/blob/master/src/runtime/select.go] ,看的时候建议是重点关注 pollorder 和 lockorder.

  • pollorder保存的是scase的序号,乱序是为了之后执行时的随机性。
  • lockorder保存了所有case中channel的地址,这里按照地址大小堆排了一下lockorder对应的这片连续内存。对chan排序是为了去重,保证之后对所有channel上锁时不会重复上锁。

goroutine作为Golang并发的核心,我们不仅要关注它们的创建和管理,当然还要关注如何合理的退出这些协程,不(合理)退出不然可能会造成阻塞、panic、程序行为异常、数据结果不正确等问题。goroutine在退出方面,不像线程和进程,不能通过某种手段强制关闭它们,只能等待goroutine主动退出。

goroutine的优雅退出方法有三种:

  1. 使用for-range退出

for-range是使用频率很高的结构,常用它来遍历数据,range能够感知channel的关闭,当channel被发送数据的协程关闭时,range就会结束,接着退出for循环。

它在并发中的使用场景是:当协程只从1个channel读取数据,然后进行处理,处理后协程退出。下面这个示例程序,当in通道被关闭时,协程可自动退出。

go func(in <-chan int) {
    // Using for-range to exit goroutine
    // range has the ability to detect the close/end of a channel
    for x := range in {
        fmt.Printf("Process %d\n", x)
    }
}(in)
  1. 使用select case ,ok退出

for-select也是使用频率很高的结构,select提供了多路复用的能力,所以for-select可以让函数具有持续多路处理多个channel的能力。但select没有感知channel的关闭,这引出了2个问题:

继续在关闭的通道上读,会读到通道传输数据类型的零值,如果是指针类型,读到nil,继续处理还会产生nil。 继续在关闭的通道上写,将会panic。

问题2可以这样解决,通道只由发送方关闭,接收方不可关闭,即某个写通道只由使用该select的协程关闭,select中就不存在继续在关闭的通道上写数据的问题。

问题1可以使用,ok来检测通道的关闭,使用情况有2种。

第一种:如果某个通道关闭后,需要退出协程,直接return即可。示例代码中,该协程需要从in通道读数据,还需要定时打印已经处理的数量,有2件事要做,所有不能使用for-range,需要使用for-select,当in关闭时,ok=false,我们直接返回。

go func() {
	// in for-select using ok to exit goroutine
	for {
		select {
		case x, ok := <-in:
			if !ok {
				return
			}
			fmt.Printf("Process %d\n", x)
			processedCnt++
		case <-t.C:
			fmt.Printf("Working, processedCnt = %d\n", processedCnt)
		}
	}
}()

第二种:如果某个通道关闭了,不再处理该通道,而是继续处理其他case,退出是等待所有的可读通道关闭。我们需要使用select的一个特征:select不会在nil的通道上进行等待。这种情况,把只读通道设置为nil即可解决。

go func() {
	// in for-select using ok to exit goroutine
	for {
		select {
		case x, ok := <-in1:
			if !ok {
				in1 = nil
			}
			// Process
		case y, ok := <-in2:
			if !ok {
				in2 = nil
			}
			// Process
		case <-t.C:
			fmt.Printf("Working, processedCnt = %d\n", processedCnt)
		}

		// If both in channel are closed, goroutine exit
		if in1 == nil && in2 == nil {
			return
		}
	}
}()
  1. 使用退出通道退出

使用,ok来退出使用for-select协程,解决是当读入数据的通道关闭时,没数据读时程序的正常结束。想想下面这2种场景,,ok还能适用吗?

接收的协程要退出了,如果它直接退出,不告知发送协程,发送协程将阻塞。启动了一个工作协程处理数据,如何通知它退出?

使用一个专门的通道,发送退出的信号,可以解决这类问题。以第2个场景为例,协程入参包含一个停止通道stopCh,当stopCh被关闭,case <-stopCh会执行,直接返回即可。

当我启动了100个worker时,只要main()执行关闭stopCh,每一个worker都会都到信号,进而关闭。如果main()向stopCh发送100个数据,这种就低效了。

func worker(stopCh <-chan struct{}) {
	go func() {
		defer fmt.Println("worker exit")
		// Using stop channel explicit exit
		for {
			select {
			case <-stopCh:
				fmt.Println("Recv stop signal")
				return
			case <-t.C:
				fmt.Println("Working .")
			}
		}
	}()
	return
}

通过channel控制子goroutine的方法可以总结为:循环监听一个channel,一般来说是for循环里放一个select监听channel以达到通知子goroutine的效果。再借助Waitgroup,主进程可以等待所有协程优雅退出后再结束自己的运行,这就通过channel实现了优雅控制goroutine并发的开始和结束。

因此在退出协程的时候需要注意:

  • 发送协程主动关闭通道,接收协程不关闭通道。技巧:把接收方的通道入参声明为只读,如果接收协程关闭只读协程,编译时就会报错。
  • 协程处理1个通道,并且是读时,协程优先使用for-range,因为range可以关闭通道的关闭自动退出协程。
  • ,ok可以处理多个读通道关闭,需要关闭当前使用for-select的协程。
  • 显式关闭通道stopCh可以处理主动通知协程退出的场景。

56. context包的用途是什么?

在 Go http包的Server中,每一个请求在都有一个对应的 goroutine 去处理。请求处理函数通常会启动额外的 goroutine 用来访问后端服务,比如数据库和RPC服务。用来处理一个请求的 goroutine 通常需要访问一些与请求特定的数据,比如终端用户的身份认证信息、验证相关的token、请求的截止时间。 当一个请求被取消或超时时,所有用来处理该请求的 goroutine 都应该迅速退出,然后系统才能释放这些 goroutine 占用的资源。

在Google 内部,我们开发了 Context 包,专门用来简化 对于处理单个请求的多个 goroutine 之间与请求域的数据、取消信号、截止时间等相关操作,这些操作可能涉及多个 API 调用。

context的数据结构是:

// A Context carries a deadline, cancelation signal, and request-scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
    // Done returns a channel that is closed when this `Context` is canceled
    // or times out.
    Done() <-chan struct{}

    // Err indicates why this Context was canceled, after the Done channel
    // is closed.
    Err() error

    // Deadline returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok bool)

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}

Context中的方法:

  • Done会返回一个channel,当该context被取消的时候,该channel会被关闭,同时对应的使用该context的routine也应该结束并返回。
  • Context中的方法是协程安全的,这也就代表了在父routine中创建的context,可以传递给任意数量的routine并让他们同时访问。
  • Deadline会返回一个超时时间,routine获得了超时时间后,可以对某些io操作设定超时时间。
  • Value可以让routine共享一些数据,当然获得数据是协程安全的。

这里需要注意一点的是在goroutine中使用context包的时候,通常我们需要在goroutine中新创建一个上下文的context,原因是:如果直接传递外部context到协层中,一个请求可能在主函数中已经结束,在goroutine中如果还没有结束的话,会直接导致goroutine中的运行的被取消.

go func() {
   _, ctx, _ := log.FromContextOrNew(context.Background(), nil)
}()

context.Background函数的返回值是一个空的context,经常作为树的根结点,它一般由接收请求的第一个routine创建,不能被取消、没有值、也没有过期时间。

Background函数的声明如下:

// Background returns an empty Context. It is never canceled, has no deadline,
// and has no values. Background is typically used in main, init, and tests,
// and as the top-level `Context` for incoming requests.
func Background() Context

WithCancel 和 WithTimeout 函数 会返回继承的 Context 对象, 这些对象可以比它们的父 Context 更早地取消。

当请求处理函数返回时,与该请求关联的 Context 会被取消。 当使用多个副本发送请求时,可以使用 WithCancel取消多余的请求。 WithTimeout 在设置对后端服务器请求截止时间时非常有用。 下面是这三个函数的声明:

// WithCancel returns a copy of parent whose Done channel is closed as soon as
// parent.Done is closed or cancel is called.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// A CancelFunc cancels a Context.
type CancelFunc func()

// WithTimeout returns a copy of parent whose Done channel is closed as soon as
// parent.Done is closed, cancel is called, or timeout elapses. The new
// Context's Deadline is the sooner of now+timeout and the parent's deadline, if
// any. If the timer is still running, the cancel function releases its
// resources.
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

调用CancelFunc对象将撤销对应的Context对象,这样父结点的所在的环境中,获得了撤销子节点context的权利,当触发某些条件时,可以调用CancelFunc对象来终止子结点树的所有routine。在子节点的routine中,需要判断何时退出routine:

select {
    case <-cxt.Done():
        // do some cleaning and return
}

根据cxt.Done()判断是否结束。当顶层的Request请求处理结束,或者外部取消了这次请求,就可以cancel掉顶层context,从而使整个请求的routine树得以退出。

WithDeadline和WithTimeout比WithCancel多了一个时间参数,它指示context存活的最长时间。如果超过了过期时间,会自动撤销它的子context。所以context的生命期是由父context的routine和deadline共同决定的。

WithValue 函数能够将请求作用域的数据与 Context 对象建立关系。声明如下:

type valueCtx struct {
    Context
    key, val interface{}
}

func WithValue(parent Context, key, val interface{}) Context {
    if key == nil {
        panic("nil key")
    }
    ......
    return &valueCtx{parent, key, val}
}

func (c *valueCtx) Value(key interface{}) interface{} {
    if c.key == key {
        return c.val
    }
    return c.Context.Value(key)
}

WithValue返回parent的一个副本,该副本保存了传入的key/value,而调用Context接口的Value(key)方法就可以得到val。注意在同一个context中设置key/value,若key相同,值会被覆盖。

context上下文数据的存储就像一个树,每个结点只存储一个key/value对。WithValue()保存一个key/value对,它将父context嵌入到新的子context,并在节点中保存了key/value数据。Value()查询key对应的value数据,会从当前context中查询,如果查不到,会递归查询父context中的数据。

值得注意的是,context中的上下文数据并不是全局的,它只查询本节点及父节点们的数据,不能查询兄弟节点的数据。

Context 使用原则:

  • 不要把Context放在结构体中,要以参数的方式传递。
  • 以Context作为参数的函数方法,应该把Context作为第一个参数,放在第一位。
  • 给一个函数方法传递Context的时候,不要传递nil,如果不知道传递什么,就使用context.TODO。
  • Context的Value相关方法应该传递必须的数据,不要什么数据都使用这个传递。
  • Context是线程安全的,可以放心的在多个goroutine中传递。

57. client如何实现长连接?

TCP协议的KeepAlive机制与HeartBeat心跳包

  • HeartBeat心跳包

很多应用层协议都有HeartBeat机制,通常是客户端每隔一小段时间向服务器发送一个数据包,通知服务器自己仍然在线,并传输一些可能必要的数据。使用心跳包的典型协议是IM,比如QQ/MSN/飞信等协议。

心跳包之所以叫心跳包是因为:它像心跳一样每隔固定时间发一次,以此来告诉服务器,这个客户端还活着。事实上这是为了保持长连接,至于这个包的内容,是没有什么特别规定的,不过一般都是很小的包,或者只包含包头的一个空包。

在TCP的机制里面,本身是存在有心跳包的机制的,也就是TCP的选项:SO_KEEPALIVE。系统默认是设置的2小时的心跳频率。但是它检查不到机器断电、网线拔出、防火墙这些断线。而且逻辑层处理断线可能也不是那么好处理。一般,如果只是用于保活还是可以的。

心跳包一般来说都是在逻辑层发送空的echo包来实现的。下一个定时器,在一定时间间隔下发送一个空包给客户端,然后客户端反馈一个同样的空包回来,服务器如果在一定时间内收不到客户端发送过来的反馈包,那就只有认定说掉线了。

其实,要判定掉线,只需要send或者recv一下,如果结果为零,则为掉线。但是,在长连接下,有可能很长一段时间都没有数据往来。理论上说,这个连接是一直保持连接的,但是实际情况中,如果中间节点出现什么故障是难以知道的。更要命的是,有的节点(防火墙)会自动把一定时间之内没有数据交互的连接给断掉。在这个时候,就需要我们的心跳包了,用于维持长连接,保活。

在获知了断线之后,服务器逻辑可能需要做一些事情,比如断线后的数据清理呀,重新连接呀……当然,这个自然是要由逻辑层根据需求去做了。

总的来说,心跳包主要也就是用于长连接的保活和断线处理。一般的应用下,判定时间在30-40秒比较不错。如果实在要求高,那就在6-9秒。

  • TCP协议的KeepAlive机制

TCP的IP传输层的两个主要协议是UDP和TCP,其中UDP是无连接的、面向packet的,而TCP协议是有连接、面向流的协议。

TCP的KeepAlive机制,首先它貌似默认是不打开的,要用setsockopt将SOL_SOCKET.SO_KEEPALIVE设置为1才是打开,并且可以设置三个参数tcp_keepalive_time/tcp_keepalive_probes/tcp_keepalive_intvl,分别表示连接闲置多久开始发keepalive的ack包、发几个ack包不回复才当对方死了、两个ack包之间间隔多长,在我测试的Ubuntu Server 10.04下面默认值是7200秒(2个小时,要不要这么蛋疼啊!)、9次、75秒。

于是连接就了有一个超时时间窗口,如果连接之间没有通信,这个时间窗口会逐渐减小,当它减小到零的时候,TCP协议会向对方发一个带有ACK标志的空数据包(KeepAlive探针),对方在收到ACK包以后,如果连接一切正常,应该回复一个ACK;如果连接出现错误了(例如对方重启了,连接状态丢失),则应当回复一个RST;如果对方没有回复,服务器每隔intvl的时间再发ACK,如果连续probes个包都被无视了,说明连接被断开了。

在http早期,每个http请求都要求打开一个tpc socket连接,并且使用一次之后就断开这个tcp连接。

使用keep-alive可以改善这种状态,即在一次TCP连接中可以持续发送多份数据而不会断开连接。通过使用keep-alive机制,可以减少tcp连接建立次数,也意味着可以减少TIME_WAIT状态连接,以此提高性能和提高httpd服务器的吞吐率(更少的tcp连接意味着更少的系统内核调用,socket的accept()和close()调用)。

但是,keep-alive并不是免费的午餐,长时间的tcp连接容易导致系统资源无效占用。配置不当的keep-alive,有时比重复利用连接带来的损失还更大。所以,正确地设置keep-alive timeout时间非常重要。

使用http keep-alvie,可以减少服务端TIME_WAIT数量(因为由服务端httpd守护进程主动关闭连接)。道理很简单,相较而言,启用keep-alive,建立的tcp连接更少了,自然要被关闭的tcp连接也相应更少了。

使用启用keepalive的不同。另外,http keepalive是客户端浏览器与服务端httpd守护进程协作的结果,所以,我们另外安排篇幅介绍不同浏览器的各种情况对keepalive的利用。

 

58. 主协程如何等其余协程完再操作?

Go提供了更简单的方法——使用sync.WaitGroup。WaitGroup,就是用来等待一组操作完成的。WaitGroup内部实现了一个计数器,用来记录未完成的操作个数,它提供了三个方法,Add()用来添加计数。Done()用来在操作结束时调用,使计数减一。Wait()用来等待所有的操作结束,即计数变为0,该函数会在计数不为0时等待,在计数为0时立即返回。

应用示例:

package main

import (
    "fmt"
    "sync"
)

func main() {

    var wg sync.WaitGroup

    wg.Add(2) // 因为有两个动作,所以增加2个计数
    go func() {
        fmt.Println("Goroutine 1")
        wg.Done() // 操作完成,减少一个计数
    }()

    go func() {
        fmt.Println("Goroutine 2")
        wg.Done() // 操作完成,减少一个计数
    }()

    wg.Wait() // 等待,直到计数为0
}

运行输出:

Goroutine 2
Goroutine 1

59. slice,len,cap,共享,扩容.

slice在Go的运行时库中就是一个C语言动态数组的实现:

struct    Slice
    {    // must not move anything
        byte*    array;        // actual data
        uintgo    len;        // number of elements
        uintgo    cap;        // allocated number of elements
    };

这个结构有3个字段,第一个字段表示array的指针,就是真实数据的指针(这个一定要注意),所以才经常说slice是数组的引用,第二个是表示slice的长度,第三个是表示slice的容量,这里需要注意:len和cap都不是指针。

在对slice进行append等操作时,可能会造成slice的自动扩容。其扩容时的大小增长规则是:

  • 如果切片的容量小于1024个元素,那么扩容的时候slice的cap就翻番,乘以2;一旦元素个数超过1024个元素,增长因子就变成1.25,即每次增加原来容量的四分之一。
  • 如果扩容之后,还没有触及原数组的容量,那么,切片中的指针指向的位置,就还是原数组,如果扩容之后,超过了原数组的容量,那么,Go就会开辟一块新的内存,把原来的值拷贝过来,这种情况丝毫不会影响到原数组。

通过slice源码可以看到,append的实现只是简单的在内存中将旧slice复制给新slice.

newcap := old.cap
if newcap+newcap < cap {
    newcap = cap
} else {
    for {
        if old.len < 1024 {
            newcap += newcap
        } else {
            newcap += newcap / 4
        }
        if newcap >= cap {
            break
        }
    }
}

60. map如何顺序读取?

可以通过sort中的排序包进行对map中的key进行排序.

package main

import (
    "fmt"
    "sort"
)

func main() {
    var m = map[string]int{
        "hello":         0,
        "morning":       1,
        "my":            2,
        "girl":   		3,
    }
    var keys []string
    for k := range m {
        keys = append(keys, k)
    }
    sort.Strings(keys)
    for _, k := range keys {
        fmt.Println("Key:", k, "Value:", m[k])
    }
}

61. 实现set

根据go中map的keys的无序性和唯一性,可以将其作为set

package main

import (
	"fmt"
	"sort"
	"sync"
)

type Set struct {
	m map[int]bool
	sync.RWMutex
}

func New() *Set {
	return &Set{
		m: map[int]bool{},
	}
}

func (s *Set) Add(item int) {
	s.Lock()
	defer s.Unlock()
	s.m[item] = true
}

func (s *Set) Remove(item int) {
	s.Lock()
	defer s.Unlock()
	delete(s.m, item)
}

func (s *Set) Has(item int) bool {
	s.RLock()
	defer s.RUnlock()
	_, ok := s.m[item]
	return ok
}

func (s *Set) Len() int {
	return len(s.List())
}

func (s *Set) Clear() {
	s.Lock()
	defer s.Unlock()
	s.m = map[int]bool{}
}

func (s *Set) IsEmpty() bool {
	if s.Len() == 0 {
		return true
	}
	return false
}

func (s *Set) List() []int {
	s.RLock()
	defer s.RUnlock()
	list := []int{}
	for item := range s.m {
		list = append(list, item)
	}
	return list
}
func (s *Set) SortList() []int {
	s.RLock()
	defer s.RUnlock()
	list := []int{}
	for item := range s.m {
		list = append(list, item)
	}
	sort.Ints(list)
	return list
}

func main() {
	//初始化
	s := New()

	s.Add(1)
	s.Add(1)
	s.Add(0)
	s.Add(2)
	s.Add(4)
	s.Add(3)

	s.Clear()
	if s.IsEmpty() {
		fmt.Println("0 item")
	}

	s.Add(1)
	s.Add(2)
	s.Add(3)

	if s.Has(2) {
		fmt.Println("2 does exist")
	}

	s.Remove(2)
	s.Remove(3)
	fmt.Println("无序的切片", s.List())
	fmt.Println("有序的切片", s.SortList())
}

62. 虚拟内存是什么?

我们都知道一个进程是与其他进程共享CPU和内存资源的。正因如此,操作系统需要有一套完善的内存管理机制才能防止进程之间内存泄漏的问题.

为了更加有效地管理内存并减少出错,现代操作系统提供了一种对主存的抽象概念,即是虚拟内存(Virtual Memory)。虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。

虚拟内存的重要意义是它定义了一个连续的虚拟地址空间,使得程序的编写难度降低。并且,把内存扩展到硬盘空间只是使用虚拟内存的必然结果,虚拟内存空间会存在硬盘中,并且会被内存缓存(按需),有的操作系统还会在内存不够的情况下,将某一进程的内存全部放入硬盘空间中,并在切换到该进程时再从硬盘读取.

虚拟内存主要提供了如下三个重要的能力:

  • 它把主存看作为一个存储在硬盘上的虚拟地址空间的高速缓存,并且只在主存中缓存活动区域(按需缓存)。

  • 它为每个进程提供了一个一致的地址空间,从而降低了程序员对内存管理的复杂性。

  • 它还保护了每个进程的地址空间不会被其他进程破坏。

63. 如何对一个20GB的文件进行排序

内存肯定没有20GB大,所以不可能采用传统排序法。但是可以将文件分成许多块,每块xMB,针对每个块各自进行排序,存回文件系统。然后将这些块逐一合并,最终得到全部排好序的文件。

外排序的一个例子是外归并排序(External merge sort),它读入一些能放在内存内的数据量,在内存中排序后输出为一个顺串(即是内部数据有序的临时文件),处理完所有的数据后再进行归并。比如,要对900MB的数据进行排序,但机器上只有100 MB的可用内存时,外归并排序按如下方法操作:

读入100 MB的数据至内存中,用某种常规方式(如快速排序、堆排序、归并排序等方法)在内存中完成排序。 将排序完成的数据写入磁盘。

重复步骤1和2直到所有的数据都存入了不同的100 MB的块(临时文件)中。在这个例子中,有900 MB数据,单个临时文件大小为100 MB,所以会产生9个临时文件。 读入每个临时文件(顺串)的前10 MB( = 100 MB / (9块 + 1))的数据放入内存中的输入缓冲区,最后的10 MB作为输出缓冲区。(实践中,将输入缓冲适当调小,而适当增大输出缓冲区能获得更好的效果。)

执行九路归并算法,将结果输出到输出缓冲区。一旦输出缓冲区满,将缓冲区中的数据写出至目标文件,清空缓冲区。一旦9个输入缓冲区中的一个变空,就从这个缓冲区关联的文件,读入下一个10M数据,除非这个文件已读完。这是“外归并排序”能在主存外完成排序的关键步骤,因为“归并算法”(merge algorithm)对每一个大块只是顺序地做一轮访问(进行归并),每个大块不用完全载入主存。

64.基本排序,哪些是稳定的.

选择排序、快速排序、希尔排序、堆排序不是稳定的排序算法,冒泡排序、插入排序、归并排序和基数排序是稳定的排序算法.

排序算法的稳定性这个应该是清晰明了的,通俗地讲就是能保证排序前2个相等的数其在序列的前后位置顺序和排序后它们两个的前后位置顺序相同。在简单形式化一下,如果Ai = Aj,Ai原来在位置前,排序后Ai还是要在Aj位置前。

其次,说一下稳定性的好处。排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,第一个键排序的结果可以为第二个键排序所用。基数排序就是这样,先按低位排序,逐次按高位排序,低位相同的元素其顺序再高位也相同时是不会改变的。另外,如果排序算法稳定,对基于比较的排序算法而言,元素交换的次数可能会少一些。

现在我们来分析一下常见的排序算法的稳定性。

(1)冒泡排序

冒泡排序就是把小的元素往前调或者把大的元素往后调。比较是相邻的两个元素比较,交换也发生在这两个元素之间。所以,如果两个元素相等,我想你是不会再无聊地把他们俩交换一下的;如果两个相等的元素没有相邻,那么即使通过前面的两两交换把两个相邻起来,这时候也不会交换,所以相同元素的前后顺序并没有改变,所以冒泡排序是一种稳定排序算法。

(2)选择排序

选择排序是给每个位置选择当前元素最小的,比如给第一个位置选择最小的,在剩余元素里面给第二个元素选择第二小的,依次类推,直到第n - 1个元素,第n个元素不用选择了,因为只剩下它一个最大的元素了。那么,在一趟选择,如果当前元素比一个元素小,而该小的元素又出现在一个和当前元素相等的元素后面,那么交换后稳定性就被破坏了。比较拗口,举个例子,序列5 8 5 2 9,我们知道第一遍选择第1个元素5会和2交换,那么原序列中2个5的相对前后顺序就被破坏了,所以选择排序不是一个稳定的排序算法。

(3)插入排序

插入排序是在一个已经有序的小序列的基础上,一次插入一个元素。当然,刚开始这个有序的小序列只有1个元素,就是第一个元素。比较是从有序序列的末尾开始,也就是想要插入的元素和已经有序的最大者开始比起,如果比它大则直接插入在其后面,否则一直往前找直到找到它该插入的位置。如果碰见一个和插入元素相等的,那么插入元素把想插入的元素放在相等元素的后面。所以,相等元素的前后顺序没有改变,从原无序序列出去的顺序就是排好序后的顺序,所以插入排序是稳定的。

(4)快速排序

快速排序有两个方向,左边的i下标一直往右走,当 a[i] <= a[center index],其中center_index是中枢元素的数组下标,一般取为数组第0个元素。而右边的j下标一直往左走,当a[j] > a[center_index]。如果i和j都走不动了,i <= j,交换a[i]和a[j],重复上面的过程,直到i > j。 交换a[j]和a[center_index],完成一趟快速排序。在中枢元素和a[j]交换的时候,很有可能把前面的元素的稳定性打乱,比如序列为5 3 3 4 3 8 9 10 11,现在中枢元素5和3(第5个元素,下标从1开始计)交换就会把元素3的稳定性打乱,所以快速排序是一个不稳定的排序算法,不稳定发生在中枢元素和a[j] 交换的时刻。

(5)归并排序

归并排序是把序列递归地分成短序列,递归出口是短序列只有1个元素(认为直接有序)或者2个序列(1次比较和交换),然后把各个有序的段序列合并成一个有序的长序列,不断合并直到原序列全部排好序。可以发现,在1个或2个元素时,1个元素不会交换,2个元素如果大小相等也没有人故意交换,这不会破坏稳定性。那么,在短的有序序列合并的过程中,稳定是是否受到破坏?没有,合并过程中我们可以保证如果两个当前元素相等时,我们把处在前面的序列的元素保存在结果序列的前面,这样就保证了稳定性。所以,归并排序也是稳定的排序算法。

(6)基数排序

基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序,最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。基数排序基于分别排序,分别收集,所以其是稳定的排序算法。

(7)希尔排序(shell)

希尔排序是按照不同步长对元素进行插入排序,当刚开始元素很无序的时候,步长最大,所以插入排序的元素个数很少,速度很快;当元素基本有序了,步长很小, 插入排序对于有序的序列效率很高。所以,希尔排序的时间复杂度会比O(n^2)好一些。由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以shell排序是不稳定的。

(8)堆排序

我们知道堆的结构是节点i的孩子为2 * i和2 * i + 1节点,大顶堆要求父节点大于等于其2个子节点,小顶堆要求父节点小于等于其2个子节点。在一个长为n 的序列,堆排序的过程是从第n / 2开始和其子节点共3个值选择最大(大顶堆)或者最小(小顶堆),这3个元素之间的选择当然不会破坏稳定性。但当为n / 2 - 1, n / 2 - 2, ... 1这些个父节点选择元素时,就会破坏稳定性。有可能第n / 2个父节点交换把后面一个元素交换过去了,而第n / 2 - 1个父节点把后面一个相同的元素没 有交换,那么这2个相同的元素之间的稳定性就被破坏了。所以,堆排序不是稳定的排序算法。

因此会有: 选择排序、快速排序、希尔排序、堆排序不是稳定的排序算法,而冒泡排序、插入排序、归并排序和基数排序是稳定的排序算法

65. Http中的Get和Head头

HTTP是一个应用层协议,主要用于Web开发,通常由HTTP客户端发起一个请求,创建一个到服务器指定端口(默认是80端口)的TCP连接。HTTP服务器则在那个端口监听客户端的请求。一旦收到请求,服务器会向客户端返回一个状态,比如"HTTP/1.1 200 OK",以及返回的内容,如请求的文件、错误消息、或者其它信息。

HTTP是一个无状态的协议,也就是说服务器不会去维护与客户交互的相关信息,因此它对于事务处理没有记忆能力。举个例子来讲,你通过服务器认证后成功请求了一个资源,紧接着再次请求这一资源时,服务器仍旧会要求你表明身份。

无状态不代表HTTP不能保持TCP连接,更不能代表HTTP使用的是UDP协议(无连接)。HTTP协议中,并没有规定它支持的层。事实上,HTTP可以在任何互联网协议上,或其他网络上实现。HTTP假定其下层协议提供可靠的传输,因此,任何能够提供这种保证的协议都可以被其使用,在TCP/IP协议族使用TCP作为其传输层.

http请求由三部分组成,分别是:请求行、消息报头、请求正文。

请求行的格式如下:

Method SP Request-URI SP HTTP-Version CRLF

请求行以一个方法符号开头,后面跟着请求的URI和协议的版本,中间以空格隔开。其中

  • Method指出在由Request-URI标识的资源上所执行的方法,方法是大小写敏感的;
  • Request-URI是一个统一资源标识符(通过简单的格式化字符串,通过名称、位置、或其他任何特性标识某个资源)。
  • HTTP-Version 表示请求的HTTP协议版本;
  • CRLF表示回车和换行(除了作为结尾的 CRLF 外,不允许出现单独的 CR 或 LF 字符)

常用的请求方法如下:

方法名称含义
GET获取由Request-URI标识的任何信息(以实体的形式),如果Request-URI引用某个数据处理过程,则应该以它产生的数据作为在响应中的实体,而不是该过程的源代码文本,除非该过程碰巧输出该文本。
POST用来请求原始服务器接受请求中封装的实体作为请求行中的Request-URI标识的副属。POST主要用于向数据处理过程提供数据块,如递交表单或者是通过追加操作来扩展数据库。
PUT以提供的Request-URI存储封装的实体。
DELETE请求原始服务器删除Request-URI标识的资源。
HEAD除了服务器不能在响应中返回消息体,HEAD方法与GET相同。用来获取暗示实体的元信息,而不需要传输实体本身。常用于测试超文本链接的有效性、可用性和最近的修改。

消息报头

报头域是由名字+“:”+空格+值组成,消息报头域的名字是大小写无关的。请求消息报头包含了普通报头、请求报头、实体报头

普通报头用于所有的请求和响应消息,但并不用于被传输的实体,只用于传输的消息。比如:

  • Cache-Control:用于指定缓存指令,缓存指令是单向的(响应中出现的缓存指令在请求中未必会出现),且是独立的(一个消息的缓存指令不会影响另一个消息处理的缓存机制);
  • Date:表示消息产生的日期和时间;
  • Connection:允许发送指定连接的选项,例如指定连接是连续,或者指定“close”选项,通知服务器在响应完成后关闭连接。

请求报头允许客户端向服务器端传递请求的附加信息以及客户端自身的信息。常用的请求报头如下:

  • Host:指定被请求资源的 Internet 主机和端口号,它通常是从HTTP URL中提取出来的;
  • User-Agent:允许客户端将它的操作系统、浏览器和其它属性告诉服务器;
  • Accept:指定客户端接受哪些类型的信息,eg:Accept:image/gif,表明客户端希望接受GIF图象格式的资源;
  • Accept-Charset:指定客户端接受的字符集,缺省是任何字符集都可以接受;
  • Accept-Encoding:指定可接受的内容编码,缺省是各种内容编码都可以接受;
  • Authorization:证明客户端有权查看某个资源,当浏览器访问一个页面,如果收到服务器的响应代码为401(未授权),可以发送一个包含Authorization请求报头域的请求,要求服务器对其进行验证。

实体报头定义了关于实体正文(eg:有无实体正文)和请求所标识的资源的元信息。常用的实体报头如下:

  • Allow:GET,POST
  • Content-Encoding:文档的编码(Encode)方法,例如:gzip;
  • Content-Language:内容的语言类型,例如:zh-cn;
  • Content-Length:表示内容长度,eg:80

get:获取由Request-URI标识的任何信息(以实体的形式),如果Request-URI引用某个数据处理过程,则应该以它产生的数据作为在响应中的实体,而不是该过程的源代码文本,除非该过程碰巧输出该文本。

head: 除了服务器不能在响应中返回消息体,HEAD方法与GET相同。用来获取暗示实体的元信息,而不需要传输实体本身。常用于测试超文本链接的有效性、可用性和最近的修改。

HTTP协议之Get和Post:

Http协议定义了很多与服务器交互的方法,最基本的有4种,分别是GET, POST, PUT, DELETE。一个URL地址用于描述一个网络上的资源,而HTTP中的GET, POST, PUT, DELETE就对应着对这个资源的查,改,增,删4个操作。我们最常见的就是GET和POST了。GET一般用于获取/查询资源信息,而POST一般用于更新资源信息,主要区别如下:

  1. GET提交的数据会放在URL之后,以?分割URL和传输数据,参数之间以&相连,如EditPosts.aspx?name=test1&id=123456。POST方法是把提交的数据放在HTTP包的Body中。
  2. GET提交的数据大小有限制(因为浏览器对URL的长度有限制,实际上HTTP协议规范没有对URL长度进行限制),而POST方法提交的数据没有限制。
  3. GET方式需要使用Request.QueryString来取得变量的值,而POST方式通过Request.Form来获取变量的值,也就是说Get是通过地址栏来传值,而Post是通过提交表单来传值。
  4. 对于GET方式的请求,浏览器会把http header和data一并发送出去,服务器响应200(返回数据);而对于POST,浏览器先发送header,服务器响应100 continue,浏览器再发送data,服务器响应200 ok(返回数据)。
  5. GET方式提交数据,会带来安全问题,比如一个登录页面,通过GET方式提交数据时,用户名和密码将出现在URL上,如果页面可以被缓存或者其他人可以访问这台机器,就可以从历史记录获得该用户的账号和密码。
  6. GET在浏览器回退时是无害的,而POST会再次提交请求。GET请求参数会被完整保留在浏览器历史记录里,而POST中的参数不会被保留。
  7. 对参数的数据类型,GET只接受ASCII字符,而POST没有限制。

66. Http 401,403

401 Unauthorized: 该HTTP状态码表示认证错误,它是为了认证设计的,而不是为了授权设计的。收到401响应,表示请求没有被认证—压根没有认证或者认证不正确—但是请重新认证和重试。(一般在响应头部包含一个WWW-Authenticate来描述如何认证)。通常由web服务器返回,而不是web应用。从性质上来说是临时的东西。(服务器要求客户端重试)

403 Forbidden:该HTTP状态码是关于授权方面的。从性质上来说是永久的东西,和应用的业务逻辑相关联。它比401更具体,更实际。收到403响应表示服务器完成认证过程,但是客户端请求没有权限去访问要求的资源。

401 Unauthorized响应应该用来表示缺失或错误的认证;403 Forbidden响应应该在这之后用,当用户被认证后,但用户没有被授权在特定资源上执行操作.

67.Cookie与Session异同

Cookie 和 Session 都为了用来保存状态信息,都是保存客户端状态的机制,它们都是为了解决HTTP无状态的问题而所做的努力。

  • Cookie 机制

简单地说,cookie 就是浏览器储存在用户电脑上的一小段文本文件。cookie 是纯文本格式,不包含任何可执行的代码。一个 Web 页面或服务器告知浏览器按照一定规范来储存这些信息,并在随后的请求中将这些信息发送至服务器,Web 服务器就可以使用这些信息来识别不同的用户。大多数需要登录的网站在用户验证成功之后都会设置一个 cookie,只要这个 cookie 存在并可以,用户就可以自由浏览这个网站的任意页面。

cookie 会被浏览器自动删除,通常存在以下几种原因:

  1. 会话 cooke (Session cookie) 在会话结束时(浏览器关闭)会被删除
  2. 持久化 cookie(Persistent cookie)在到达失效日期时会被删除
  3. 如果浏览器中的 cookie 数量达到限制,那么 cookie 会被删除以为新建的 cookie 创建空间。

大多数浏览器支持最大为 4096 字节的 Cookie。由于这限制了 Cookie 的大小,最好用 Cookie 来存储少量数据,或者存储用户 ID 之类的标识符。用户 ID 随后便可用于标识用户,以及从数据库或其他数据源中读取用户信息。 浏览器还限制站点可以在用户计算机上存储的 Cookie 的数量。大多数浏览器只允许每个站点存储 20 个 Cookie;如果试图存储更多 Cookie,则最旧的 Cookie 便会被丢弃。有些浏览器还会对它们将接受的来自所有站点的 Cookie 总数作出绝对限制,通常为 300 个。

使用 Cookie 的缺点:

  • 不良站点用 Cookie 收集用户隐私信息;

  • Cookie窃取:黑客以可以通过窃取用户的cookie来模拟用户的请求行为。(跨站脚本攻击XSS)

  • Session 机制

Session机制是一种服务器端的机制,服务器使用一种类似于散列表的结构(也可能就是使用散列表)来保存信息。当程序需要为某个客户端的请求创建一个session的时候,服务器首先检查这个客户端的请求里是否已包含了一个session标识(session id):

  • 如果已包含一个session id 则说明以前已经为此客户端创建过session,服务器就按照session id把这个 session 检索出来使用(如果检索不到,可能会新建一个)。
  • 如果客户端请求不包含session id,则为此客户端创建一个session并且生成一个与此session相关联的session id,session id的值应该是一个既不会重复,又不容易被找到规律以仿造的字符串,这个 session id将被在本次响应中返回给客户端保存。

具体实现方式:

  • Cookie方式:服务器给每个Session分配一个唯一的JSESSIONID,并通过Cookie发送给客户端。当客户端发起新的请求的时候,将在Cookie头中携带这个JSESSIONID,这样服务器能够找到这个客户端对应的Session。
  • URL回写:服务器在发送给浏览器页面的所有链接中都携带JSESSIONID的参数,这样客户端点击任何一个链接都会把JSESSIONID带回服务器。如果直接在浏览器输入服务端资源的url来请求该资源,那么Session是匹配不到的。

Web 缓存

WEB缓存(cache)位于Web服务器和客户端之间,缓存机制会根据请求保存输出内容的副本,例如html页面,图片,文件,当下一个请求来到的时候:如果是相同的URL,缓存直接使用副本响应访问请求,而不是向源服务器再次发送请求。

主要分三种情况:

  1. 未找到缓存(黑色线):当没有找到缓存时,说明本地并没有这些数据,这种情况一般发生在我们首次访问网站,或者以前访问过,但是清除过缓存后。浏览器就会先访问服务器,然后把服务器上的内容取回来,内容取回来以后,就要根据情况来决定是否要保留到缓存中了。

  2. 缓存未过期(蓝色线):缓存未过期,指的是本地缓存没有过期,不需要访问服务器了,直接就可以拿本地的缓存作为响应在本地使用了。这样节省了不少网络成本,提高了用户体验过。

  3. 缓存已过期(红色线):当满足过期的条件时,会向服务器发送请求,发送的请求一般都会进行一个验证,目的是虽然缓存文档过期了,但是文档内容不一定会有什么改变,所以服务器返回的也许是一个新的文档,这时候的HTTP状态码是200,或者返回的只是一个最新的时间戳和304状态码。

    缓存过期后,有两种方法来判定服务端的文件有没有更新。第一种在上一次服务端告诉客户端约定的有效期的同时,告诉客户端该文件最后修改的时间,当再次试图从服务端下载该文件的时候,check下该文件有没有更新(对比最后修改时间),如果没有,则读取缓存;第二种方式是在上一次服务端告诉客户端约定有效期的同时,同时告诉客户端该文件的版本号,当服务端文件更新的时候,改变版本号,再次发送请求的时候check一下版本号是否一致就行了,如一致,则可直接读取缓存。

浏览器是依靠请求和响应中的的头信息来控制缓存的,如下:

  • Expires与Cache-Control:服务端用来约定和客户端的有效时间的。Expires规定了缓存失效时间(Date为当前时间),而Cache-Control的max-age规定了缓存有效时间(2552s)。Expires是HTTP1.0的东西,而Cache-Control是HTTP1.1的,规定如果max-age和Expires同时存在,前者优先级高于后者。

  • Last-Modified/If-Modified-Since:缓存过期后,check服务端文件是否更新的第一种方式。

  • ETag/If-None-Match:缓存过期时check服务端文件是否更新的第二种方式。实际上ETag并不是文件的版本号,而是一串可以代表该文件唯一的字符串,当客户端发现和服务器约定的直接读取缓存的时间过了,就在请求中发送If-None-Match选项,值即为上次请求后响应头的ETag值,该值在服务端和服务端代表该文件唯一的字符串对比(如果服务端该文件改变了,该值就会变),如果相同,则相应HTTP304,客户端直接读取缓存,如果不相同,HTTP200,下载正确的数据,更新ETag值。

当然并不是所有请求都能被缓存。无法被浏览器缓存的请求:

  1. HTTP信息头中包含Cache-Control:no-cache,pragma:no-cache(HTTP1.0),或Cache-Control:max-age=0等告诉浏览器不用缓存的请求
  2. 需要根据Cookie,认证信息等决定输入内容的动态请求是不能被缓存的
  3. POST请求无法被缓存

浏览器缓存过程还和用户行为有关。譬如先打开一个主页有个jquery的请求(假设访问后会缓存下来)。接着如果直接在地址栏输入 jquery 地址,然后回车,响应HTTP200(from cache),因为有效期还没过直接读取的缓存;如果ctrl+r进行刷新,则会相应HTTP304(Not Modified),虽然还是读取的本地缓存,但是多了一次服务端的请求;而如果是ctrl+shift+r强刷,则会直接从服务器下载新的文件,响应HTTP200。

68. Http能不能一次连接多次请求,不等后端返回.

早期的tcp确实一个connection一个request,不过后来keepalive和persistent tcp connection 已经是标准实现,一个connection一般都会有多个request response的交互, 其次,tcp是保证顺序的投递服务所以不会出现你所说的乱序,如果你使用三个conn来发三个服务倒是有可能由于路由或者掉包重传导致到达顺序随机

此外, http是无状态协议,所以乱序不应该成为问题,每个request response都应该相对独立.

69. TCP 和 UDP 有什么区别,适用场景.

  • TCP 是面向连接的,UDP 是面向无连接的,故 TCP 需要建立连接和断开连接,UDP 不需要。

  • TCP 是流协议,UDP 是数据包协议,故 TCP 数据没有大小限制,UDP 数据报有大小限制(UDP 协议本身限制、数据链路层的 MTU、缓存区大小)。

  • TCP 是可靠协议,UDP 是不可靠协议;故 TCP 会处理数据丢包重发以及乱序等情况,UDP 则不会处理。

UDP 的特点及使用场景:

UDP 不提供复杂的控制机制,利用 IP 提供面向无连接的通信服务,随时都可以发送数据,处理简单且高效,经常用于以下场景:

包总量较小的通信(DNS、SNMP)

视频、音频等多媒体通信(即时通信)

广播通信:

TCP 的特点及使用场景:

相对于 UDP,TCP 实现了数据传输过程中的各种控制,可以进行丢包时的重发控制,还可以对次序乱掉的分包进行顺序控制。

在对可靠性要求较高的情况下,可以使用 TCP,即不考虑 UDP 的时候,都可以选择 TCP。

70. TIME_WAIT的作用

主动关闭的Socket端会进入TIME_WAIT状态,并且持续2MSL时间长度,MSL就是maximum segment lifetime(最大分节生命期),这是一个IP数据包能在互联网上生存的最长时间,超过这个时间将在网络中消失。MSL在RFC 1122上建议是2分钟,而源自berkeley的TCP实现传统上使用30秒,因而,TIME_WAIT状态一般维持在1-4分钟。

  • 可靠地实现TCP全双工连接的终止

在进行关闭连接四路握手协议时,最后的ACK是由主动关闭端发出的,如果这个最终的ACK丢失,服务器将重发最终的FIN,因此客户端必须维护状态信息允 许它重发最终的ACK。如果不维持这个状态信息,那么客户端将响应RST分节,服务器将此分节解释成一个错误(在java中会抛出connection reset的SocketException)。因而,要实现TCP全双工连接的正常终止,必须处理终止序列四个分节中任何一个分节的丢失情况,主动关闭 的客户端必须维持状态信息进入TIME_WAIT状态。

  • 允许老的重复分节在网络中消逝

TCP分节可能由于路由器异常而“迷途”,在迷途期间,TCP发送端可能因确认超时而重发这个分节,迷途的分节在路由器修复后也会被送到最终目的地,这个 原来的迷途分节就称为lost duplicate。在关闭一个TCP连接后,马上又重新建立起一个相同的IP地址和端口之间的TCP连接,后一个连接被称为前一个连接的化身 (incarnation),那么有可能出现这种情况,前一个连接的迷途重复分组在前一个连接终止后出现,从而被误解成从属于新的化身。为了避免这个情 况,TCP不允许处于TIME_WAIT状态的连接启动一个新的化身,因为TIME_WAIT状态持续2MSL,就可以保证当成功建立一个TCP连接的时 候,来自连接先前化身的重复分组已经在网络中消逝。

71. 数据库如何建索引

MySQL索引类型包括:

  • 普通索引
  • 唯一索引
  • 主键索引
  • 组合索引

72. 孤儿进程,僵尸进程

  • 孤儿进程:一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。

  • 僵尸进程:一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中。这种进程称之为僵死进程。

73. 死锁条件,如何避免.

死锁是指多个进程因竞争资源而造成的一种僵局(互相等待),若无外力作用,这些进程都将无法向前推进。例如,在某一个计算机系统中只有一台打印机和一台输入 设备,进程P1正占用输入设备,同时又提出使用打印机的请求,但此时打印机正被进程P2 所占用,而P2在未释放打印机之前,又提出请求使用正被P1占用着的输入设备。这样两个进程相互无休止地等待下去,均无法继续执行,此时两个进程陷入死锁状态。

死锁产生的原因:

  1. 系统资源的竞争

系统资源的竞争导致系统资源不足,以及资源分配不当,导致死锁。

  1. 进程运行推进顺序不合适

进程在运行过程中,请求和释放资源的顺序不当,会导致死锁。

死锁的四个必要条件:

互斥条件:一个资源每次只能被一个进程使用,即在一段时间内某 资源仅为一个进程所占有。此时若有其他进程请求该资源,则请求进程只能等待。

请求与保持条件:进程已经保持了至少一个资源,但又提出了新的资源请求,而该资源 已被其他进程占有,此时请求进程被阻塞,但对自己已获得的资源保持不放。

不可剥夺条件:进程所获得的资源在未使用完毕之前,不能被其他进程强行夺走,即只能 由获得该资源的进程自己来释放(只能是主动释放)。

循环等待条件: 若干进程间形成首尾相接循环等待资源的关系

这四个条件是死锁的必要条件,只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。

死锁的避免与预防:

死锁避免的基本思想:系统对进程发出的每一个系统能够满足的资源申请进行动态检查,并根据检查结果决定是否分配资源,如果分配后系统可能发生死锁,则不予分配,否则予以分配,这是一种保证系统不进入死锁状态的动态策略。 如果操作系统能保证所有进程在有限时间内得到需要的全部资源,则系统处于安全状态否则系统是不安全的。

安全状态是指:如果系统存在 由所有的安全序列{P1,P2,…Pn},则系统处于安全状态。一个进程序列是安全的,如果对其中每一个进程Pi(i >=1 && i <= n)他以后尚需要的资源不超过系统当前剩余资源量与所有进程Pj(j < i)当前占有资源量之和,系统处于安全状态则不会发生死锁。 不安全状态:如果不存在任何一个安全序列,则系统处于不安全状态。

我们可以通过破坏死锁产生的4个必要条件来 预防死锁,由于资源互斥是资源使用的固有特性是无法改变的。

  1. 破坏“不可剥夺”条件:一个进程不能获得所需要的全部资源时便处于等待状态,等待期间他占有的资源将被隐式的释放重新加入到 系统的资源列表中,可以被其他的进程使用,而等待的进程只有重新获得自己原有的资源以及新申请的资源才可以重新启动,执行。
  2. 破坏”请求与保持条件“:第一种方法静态分配即每个进程在开始执行时就申请他所需要的全部资源。第二种是动态分配即每个进程在申请所需要的资源时他本身不占用系统资源。
  3. 破坏“循环等待”条件:采用资源有序分配其基本思想是将系统中的所有资源顺序编号,将紧缺的,稀少的采用较大的编号,在申请资源时必须按照编号的顺序进行,一个进程只有获得较小编号的进程才能申请较大编号的进程。

74. linux命令,查看端口占用,cpu负载,内存占用,如何发送信号给一个进程

linux ps命令,查看某进程cpu和内存占用率情况:

> ps aux
USER               PID  %CPU %MEM      VSZ    RSS   TT  STAT STARTED      TIME COMMAND
admin            72824  17.3  1.4  5518204 118212   ??  R    27 519   54:49.93 /Applications/iTerm.app/Contents/MacOS/iTerm2
_windowserver      179  16.1  0.6  7525352  46552   ??  Rs   21 519  457:09.25 /System/Library/PrivateFrameworks/SkyLight.fra
admin              734  12.2  3.3  6095348 273108   ??  R    21 519  635:17.25 /Users/admin/Desktop/Google Chrome.app/Content
admin            10718   9.0  2.7  5604388 223604   ??  S    22 519  557:56.89 /Users/admin/Desktop/Google Chrome.app/Content
admin              750   6.4  0.6  4633300  52372   ??  S    21 519  147:59.59 /Users/admin/Desktop/Google Chrome.app/Content
admin              749   5.6  1.2  5570904  96832   ??  S    21 519  359:56.37 /Users/admin/Desktop/Google Chrome.app/Content
admin              818   4.5  0.1  6557980   5508   ??  S    21 519  557:27.52 com.docker.hyperkit -A -u -F vms/0/hyperkit.pi
admin            32898   3.5  1.4  4977204 117684   ??  S    10:54上午   0:02.27 /Users/admin/Desktop/Google Chrome.app/Content
admin            30591   2.2  3.7  9505844 310584   ??  S     9:47上午  10:49.28 /Applications/GoLand.app/Contents/MacOS/goland
root              1300   1.9  0.1  4334916   6212   ??  Ss   21 519  123:53.86 /usr/libexec/taskgated
admin            31232   1.2  1.1 10553808  88860   ??  S    10:24上午   3:28.67 /Applications/WebStorm.app/Contents/MacOS/webs
admin            18704   0.7  0.2 19282032  12948   ??  S     3:56下午   4:18.12 /private/var/folders/kp/3yqnp9cj4f3_9539b06q4
  • linux 下的ps命令
  • USER 进程运行用户
  • PID 进程编号
  • %CPU 进程的cpu占用率
  • %MEM 进程的内存占用率
  • VSZ 进程所使用的虚存的大小
  • RSS 进程使用的驻留集大小或者是实际内存的大小
  • TTY 与进程关联的终端(tty)
  • STAT 检查的状态:进程状态使用字符表示的,如R(running正在运行或准备运行)、S(sleeping睡眠)、I(idle空闲)、Z (僵死)、D(不可中断的睡眠,通常是I/O)、P(等待交换页)、W(换出,表示当前页面不在内存)、N(低优先级任务)T(terminate终止)、W has no resident pages
  • START (进程启动时间和日期)
  • TIME ;(进程使用的总cpu时间)
  • COMMAND (正在执行的命令行命令)
  • NI (nice)优先级
  • PRI 进程优先级编号
  • PPID 父进程的进程ID(parent process id)
  • SID 会话ID(session id)
  • WCHAN 进程正在睡眠的内核函数名称;该函数的名称是从/root/system.map文件中获得的。
  • FLAGS 与进程相关的数字标识

通常在linux中可以通过pkill 命令、kill 命令和 killall 命令,或者组合键向进程发送各种信号.

  • Ctrl + C: 中断信号,发送 SIGINT 信号到运行在前台的进程.
  • Ctrl + Y: 延时挂起信号,使运行的进程在尝试从终端读取输入时停止。控制权返回给 Shell,使用户可以将进程放在前台或后台,或杀掉该进程.
  • Ctrl + Z: 挂起信号,发送 SIGTSTP 信号到运行的进程,由此将其停止,并将控制权返回给 Shell.

也可以使用 kill命令结束进程:

  • 发送 SIGKILL 信号到 PID 是 123 的进程:
> kill -9 123

killall 命令会发送信号到运行任何指定命令的所有进程。所以,当一个进程启动了多个实例时,使用 killall 命令来杀掉这些进程会更方便一些。

  • 使用 killall 命令杀掉所有 firefox 进程:
> killall firefox

使用 pkill 命令,可以通过指定进程名、用户名、组名、终端、UID、EUID和GID等属性来杀掉相应的进程。pkill 命令默认也是发送 SIGTERM 信号到进程。

  • 使用 pkill 命令杀掉所有用户的 firefox 进程.
> pkill firefox

75. git文件版本,使用顺序,merge跟rebase.

在使用 git 进行版本管理的项目中,当完成一个特性的开发并将其合并到 master 分支时,我们有两种方式:git merge 和 git rebase。

通常,我们对 git merge 使用的较多,而对于 git rebase 使用的较少,其实 git rebase 也是极其强大的一种方法。

  • git merge

git merge 的使用方法很简单,假如你想将分支 feature 合并到分支 master,那么只需执行如下两步即可:

将分支切换到 master 上去:git checkout master 将分支 feature 合并到当前分支(即 master 分支)上:git merge feature

 

git merge 有如下特点:

git merge只处理一次冲突,引入了一次合并的历史记录,合并后的所有 commit 会按照提交时间从旧到新排列所有的过程信息更多,可能会提高之后查找问题的难度

因此git merge 提交的信息过多可能会影响查找问题的难度,在一个大型项目中,单纯依靠 git merge 方法进行合并,会保存所有的提交过程的信息:引出分支,合并分支,在分支上再引出新的分支等等,类似这样的操作一多,提交历史信息就会显得杂乱,这时如果有问题需要查找就会比较困难了。

  • git rebase

git rebase 的目的也是将一个分支的更改并入到另外一个分支中去。但是git rebase会把你的所有分支信息衍合成一条信息,减少中间不必要的历史记录.

 

git rebase特点:

  • git rebase会改变当前分支从 master 上拉出分支的位置.
  • 没有多余的合并历史的记录,且合并后的 commit 顺序不一定按照 commit 的提交时间排列.
  • 可能会多次解决同一个地方的冲突(有 squash 来解决).
  • 更清爽一些,master 分支上每个 commit 点都是相对独立完整的功能单元.

因此, 当需要保留详细的合并信息的时候建议使用git merge,特别是需要将分支合并进入master分支时;当发现自己修改某个功能时,频繁进行了git commit提交时,发现其实过多的提交信息没有必要时,可以尝试git rebase。

76. 通常一般会用到哪些数据结构?

数据结构是计算机存储、组织数据的方式。对于特定的数据结构(比如数组),有些操作效率很高(读某个数组元素),有些操作的效率很低(删除某个数组元素)。开发者的目标是为当前的问题选择最优的数据结构。

  1. 数组

数组(Array)大概是最简单,也是最常用的数据结构了。其他数据结构,比如栈和队列都是由数组衍生出来的。

  1. 队列
  2. 链表
  3. 前缀树
  4. 哈希表

算法可大致分为基本算法、数据结构的算法、数论与代数算法、计算几何的算法、图论的算法、动态规划以及数值分析、加密算法、排序算法、检索算法、随机化算法、并行算法,厄米变形模型,随机森林算法。

数据对象的运算和操作:计算机可以执行的基本操作是以指令的形式描述的。一个计算机系统能执行的所有指令的集合,成为该计算机系统的指令系统。

一个计算机的基本运算和操作有如下四类:

  1. 算术运算:加减乘除等运算.
  2. 逻辑运算:或、且、非等运算.
  3. 关系运算:大于、小于、等于、不等于等运算.
  4. 数据传输:输入、输出、赋值等运算.

77. 链表和数组相比, 有什么优缺点?

78. 如何判断两个无环单链表有没有交叉点?

79. 如何判断一个单链表有没有环, 并找出入环点?

80. 描述一下 TCP 四次挥手的过程中.

81. TCP 有哪些状态?

82. TCP 的 LISTEN 状态是什么?

83. TCP 的 CLOSE_WAIT 状态是什么?

84. 建立一个 socket 连接要经过哪些步骤?

85. 常见的 HTTP 状态码有哪些?

86. 301和302有什么区别?

87. 504和500有什么区别?

88. HTTPS 和 HTTP 有什么区别?

89. 算法题: 手写一个快速排序

快速排序:

func main() {
	var arr = []int{19,8,16,15,23,34,6,3,1,0,2,9,7}
	quickAscendingSort(arr, 0, len(arr)-1)
	fmt.Println("quickAscendingSort:",arr)

	quickDescendingSort(arr, 0, len(arr)-1)
	fmt.Println("quickDescendingSort:",arr)
}

//升序
func quickAscendingSort(arr []int, start, end int) {
	if (start < end) {
		i, j := start, end
		key := arr[(start + end)/2]
		for i <= j {
			for arr[i] < key {
				i++
			}
			for arr[j] > key {
				j--
			}
			if i <= j {
				arr[i], arr[j] = arr[j], arr[i]
				i++
				j--
			}
		}

		if start < j {
			quickAscendingSort(arr, start, j)
		}
		if end > i {
			quickAscendingSort(arr, i, end)
		}
	}
}

//降序
func quickDescendingSort(arr []int, start, end int) {
	if (start < end) {
		i, j := start, end
		key := arr[(start + end)/2]
		for i <= j {
			for arr[i] > key {
				i++
			}
			for arr[j] < key {
				j--
			}
			if i <= j {
				arr[i], arr[j] = arr[j], arr[i]
				i++
				j--
			}
		}

		if start < j {
			quickDescendingSort(arr, start, j)
		}
		if end > i {
			quickDescendingSort(arr, i, end)
		}
	}
}

90. Golang 里的逃逸分析是什么?怎么避免内存逃逸?

在golang中逃逸分析是一种确定指针动态范围的方法,可以分析在程序的哪些地方可以访问到指针。它涉及到指针分析和形状分析。当一个变量(或对象)在子程序中被分配时,一个指向变量的指针可能逃逸到其它执行线程中,或者去调用子程序。如果使用尾递归优化(通常在函数编程语言中是需要的),对象也可能逃逸到被调用的子程序中。 如果一个子程序分配一个对象并返回一个该对象的指针,该对象可能在程序中的任何一个地方被访问到——这样指针就成功“逃逸”了。如果指针存储在全局变量或者其它数据结构中,它们也可能发生逃逸,这种情况是当前程序中的指针逃逸。 逃逸分析需要确定指针所有可以存储的地方,保证指针的生命周期只在当前进程或线程中。

导致内存逃逸的情况比较多,有些可能还是官方未能够实现精确的分析逃逸情况的 bug,通常来讲就是如果变量的作用域不会扩大并且其行为或者大小能够在编译的时候确定,一般情况下都是分配到栈上,否则就可能发生内存逃逸分配到堆上。

内存逃逸的五种情况:

  1. 发送指针的指针或值包含了指针到 channel 中,由于在编译阶段无法确定其作用域与传递的路径,所以一般都会逃逸到堆上分配。

  2. slices 中的值是指针的指针或包含指针字段。一个例子是类似[] *string 的类型。这总是导致 slice 的逃逸。即使切片的底层存储数组仍可能位于堆栈上,数据的引用也会转移到堆中。

  3. slice 由于 append 操作超出其容量,因此会导致 slice 重新分配。这种情况下,由于在编译时 slice 的初始大小的已知情况下,将会在栈上分配。如果 slice 的底层存储必须基于仅在运行时数据进行扩展,则它将分配在堆上。

  4. 调用接口类型的方法。接口类型的方法调用是动态调度 - 实际使用的具体实现只能在运行时确定。考虑一个接口类型为 io.Reader 的变量 r。对 r.Read(b) 的调用将导致 r 的值和字节片b的后续转义并因此分配到堆上。

  5. 尽管能够符合分配到栈的场景,但是其大小不能够在在编译时候确定的情况,也会分配到堆上

有效的避免上述的五种逃逸的情况,可以避免内存逃逸.

91. 配置中心如何保证一致性?

92. Golang 的GC触发时机是什么?

Go 语言中对 GC 的触发时机存在两种形式:

  • 主动触发,通过调用 runtime.GC 来触发 GC,此调用阻塞式地等待当前 GC 运行完毕。
  • 被动触发,分为两种方式: a. 使用系统监控,当超过两分钟没有产生任何 GC 时,强制触发 GC。 b. 使用步调(Pacing)算法,其核心思想是控制内存增长的比例。

93. Redis 里数据结构的实现熟悉吗,调表的实现原理是什么?

Redis中的set数据结构底层用的是调表实现的.

跳表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。

跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。 跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。 (1)跳表是可以实现二分查找的有序链表; (2)每个元素插入时随机生成它的level; (3)最低层包含所有的元素; (4)如果一个元素出现在level(x),那么它肯定出现在x以下的level中; (5)每个索引节点包含两个指针,一个向下,一个向右; (6)跳表查询、插入、删除的时间复杂度为O(log n),与平衡二叉树接近;

为什么Redis选择使用跳表而不是红黑树来实现有序集合?(O(logN))

首先,我们来分析下Redis的有序集合支持的操作:

  • 插入元素
  • 删除元素
  • 查找元素
  • 有序输出所有元素
  • 查找区间内所有元素

其中,前4项红黑树都可以完成,且时间复杂度与跳表一致。但是,最后一项,红黑树的效率就没有跳表高了。在跳表中,要查找区间的元素,我们只要定位到两个区间端点在最低层级的位置,然后按顺序遍历元素就可以了,非常高效。

而红黑树只能定位到端点后,再从首位置开始每次都要查找后继节点,相对来说是比较耗时的。 此外,跳表实现起来很容易且易读,红黑树实现起来相对困难,所以Redis选择使用跳表来实现有序集合。

94. Etcd的Raft一致性算法原理?

95. 微服务概念.

96. SLB原理.

  1. 分布式一直性原则.

  2. 如何保证服务宕机造成的分布式服务节点处理问题?

  3. 服务发现怎么实现的.

  4. Go中切片,map,struct 在64位机器中占用字节是多少?

在64位系统下,Golang的切片占用字节是24位,map和struct都是8位.

  1. Go中的defer函数使用下面的两种情况下结果是多少,为什么?
	a := 1
	defer fmt.Println("the value of a1:",a)
	a++

	defer func() {
		fmt.Println("the value of a2:",a)
	}()

运行:

the value of a1: 1
the value of a1: 2

第一种情况:

defer fmt.Println("the value of a1:",a)

defer延迟函数调用的fmt.Println(a)函数的参数值在defer语句出现时就已经确定了,所以无论后面如何修改a变量都不会影响延迟函数。

第二种情况:

defer func() {
		fmt.Println("the value of a2:",a)
	}()

defer延迟函数调用的函数参数的值在defer定义时候就确定了,而defer延迟函数内部所使用的值需要在这个函数运行时候才确定。

102. 如何通过递归反转单链表?

链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。 相比于线性表顺序结构,操作复杂。由于不必须按顺序存储,链表在插入的时候可以达到O(1)的复杂度,比另一种线性表顺序表快得多,但是查找一个节点或者访问特定编号的节点则需要O(n)的时间,而线性表和顺序表相应的时间复杂度分别是O(logn)和O(1)。

使用链表结构可以克服数组链表需要预先知道数据大小的缺点,链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但是链表失去了数组随机读取的优点,同时链表由于增加了结点的指针域,空间开销比较大。链表最明显的好处就是,常规数组排列关联项目的方式可能不同于这些数据项目在记忆体或磁盘上顺序,数据的存取往往要在不同的排列顺序中转换。链表允许插入和移除表上任意位置上的节点,但是不允许随机存取。链表有很多种不同的类型:单向链表,双向链表以及循环链表。链表可以在多种编程语言中实现。像Lisp和Scheme这样的语言的内建数据类型中就包含了链表的存取和操作。

package main

import "fmt"

// 通过递归反转单链表
type Node struct {
	Value int
	NextNode *Node
}


func Param(node *Node){
	for node !=nil{
		fmt.Print(node.Value,"--->")
		node = node.NextNode
	}
	fmt.Println()
}

func reverse(headNode *Node) *Node{
	if headNode ==nil {
		return headNode
	}
	if headNode.NextNode == nil{
		return headNode
	}
	var newNode = reverse(headNode.NextNode)
	headNode.NextNode.NextNode = headNode
	headNode.NextNode = nil
	return newNode
}


func main() {
	var node1 = &Node{}
	node1.Value = 1
	node2 := new(Node)
	node2.Value = 2
	node3 := new(Node)
	node3.Value = 3
	node4 := new(Node)
	node4.Value = 4
	node1.NextNode = node2
	node2.NextNode = node3
	node3.NextNode = node4
	Param(node1)
	reverseNode := reverse(node1)
	Param(reverseNode)

运行:

1--->2--->3--->4--->
4--->3--->2--->1--->

103. Mysql中utf8和utf8mb4区别?

MySQL在5.5.3之后增加了这个utf8mb4的编码,mb4就是most bytes 4的意思,专门用来兼容四字节的unicode。好在utf8mb4是utf8的超集,除了将编码改为utf8mb4外不需要做其他转换。当然,为了节省空间,一般情况下使用utf8也就可以了。

mysql支持的 utf8 编码最大字符长度为 3 字节,如果遇到 4 字节的宽字符就会插入异常了。三个字节的 UTF-8 最大能编码的 Unicode 字符是 0xffff,也就是 Unicode 中的基本多文种平面(BMP)。任何不在基本多文本平面的 Unicode字符,都无法使用 Mysql 的 utf8 字符集存储。包括 Emoji 表情(Emoji 是一种特殊的 Unicode 编码,常见于 ios 和 android 手机上),和很多不常用的汉字,以及任何新增的 Unicode 字符等等。

Mysql 中保存 4 字节长度的 UTF-8 字符,需要使用 utf8mb4 字符集,但只有 5.5.3 版本以后的才支持(查看版本: select version();)。我觉得,为了获取更好的兼容性,应该总是使用 utf8mb4 而非 utf8. 对于 CHAR 类型数据,utf8mb4 会多消耗一些空间,根据 Mysql 官方建议,使用 VARCHAR 替代 CHAR。

  1. MySQL中乐观锁和悲观锁 原理、区别?

悲观锁(Pessimistic Lock), 顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁。传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。

乐观锁(Optimistic Lock), 顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁适用于多读的应用类型,这样可以提高吞吐量,像数据库如果提供类似于write_condition机制的其实都是提供的乐观锁。

乐观锁的特点先进行业务操作,不到万不得已不去拿锁。即“乐观”的认为拿锁多半是会成功的,因此在进行完业务操作需要实际更新数据的最后一步再去拿一下锁就好。

乐观锁在数据库上的实现完全是逻辑的,不需要数据库提供特殊的支持。一般的做法是在需要锁的数据上增加一个版本号,或者时间戳,然后按照如下方式实现:

1. SELECT data AS old_data, version AS old_version FROM …;
2. 根据获取的数据进行业务操作,得到new_data和new_version
3. UPDATE SET data = new_data, version = new_version WHERE version = old_version
if (updated row > 0) {
    // 乐观锁获取成功,操作完成
} else {
    // 乐观锁获取失败,回滚并重试
}

乐观锁是否在事务中其实都是无所谓的,其底层机制是这样:在数据库内部update同一行的时候是不允许并发的,即数据库每次执行一条update语句时会获取被update行的写锁,直到这一行被成功更新后才释放。因此在业务操作进行前获取需要锁的数据的当前版本号,然后实际更新数据时再次对比版本号确认与之前获取的相同,并更新版本号,即可确认这之间没有发生并发的修改。如果更新失败即可认为老版本的数据已经被并发修改掉而不存在了,此时认为获取锁失败,需要回滚整个业务操作并可根据需要重试整个过程。

两种锁各有优缺点,不可认为一种好于另一种,像乐观锁适用于写比较少的情况下,即冲突真的很少发生的时候,这样可以省去了锁的开销,加大了系统的整个吞吐量。但如果经常产生冲突,上层应用会不断的进行retry,这样反倒是降低了性能,所以这种情况下用悲观锁就比较合适。  

105. 如何测试代码是否又goroutine泄漏的?

使用runtime.Stack在测试代码运行前后计算goroutine数量,当然我理解测试代码运行完成之后是会触发gc的。如果触发gc之后,发现还有goroutine没有被回收,那么这个goroutine很有可能是被泄漏的。

堆栈将调用goroutine的堆栈跟踪格式化为buf 并返回写入buf的字节数。如果全部为真,则在当前goroutine的跟踪之后,Stack格式化所有其他goroutine的跟踪到buf中。
func Stack(buf []byte, all bool) int {
	if all {
		stopTheWorld("stack trace")
	}

	n := 0
	if len(buf) > 0 {
		gp := getg()
		sp := getcallersp()
		pc := getcallerpc()
		systemstack(func() {
			g0 := getg()
			// Force traceback=1 to override GOTRACEBACK setting,
			// so that Stack's results are consistent.
			// GOTRACEBACK is only about crash dumps.
			g0.m.traceback = 1
			g0.writebuf = buf[0:0:len(buf)]
			goroutineheader(gp)
			traceback(pc, sp, 0, gp)
			if all {
				tracebackothers(gp)
			}
			g0.m.traceback = 0
			n = len(g0.writebuf)
			g0.writebuf = nil
		})
	}

	if all {
		startTheWorld()
	}
	return n
}

106. 值接收者和指针接收者的区别?

方法能给用户自定义的类型添加新的行为。它和函数的区别在于方法有一个接收者,给一个函数添加一个接收者,那么它就变成了方法。接收者可以是值接收者,也可以是指针接收者。

在调用方法的时候,值类型既可以调用值接收者的方法,也可以调用指针接收者的方法;指针类型既可以调用指针接收者的方法,也可以调用值接收者的方法。

也就是说,不管方法的接收者是什么类型,该类型的值和指针都可以调用,不必严格符合接收者的类型。

package main

import "fmt"

type Person struct {
    age int
}

func (p Person) Elegance() int {
    return p.age
}

func (p *Person) GetAge() {
    p.age += 1
}

func main() {
    // p1 是值类型
    p := Person{age: 18}

    // 值类型 调用接收者也是值类型的方法
    fmt.Println(p.howOld())

    // 值类型 调用接收者是指针类型的方法
    p.GetAge()
    fmt.Println(p.GetAge())

    // ----------------------

    // p2 是指针类型
    p2 := &Person{age: 100}

    // 指针类型 调用接收者是值类型的方法
    fmt.Println(p2.GetAge())

    // 指针类型 调用接收者也是指针类型的方法
    p2.GetAge()
    fmt.Println(p2.GetAge())
}

运行

18
19
100
101
函数和方法值接收者指针接收者
值类型调用者方法会使用调用者的一个副本,类似于“传值”使用值的引用来调用方法,上例中,p1.GetAge() 实际上是 (&p1).GetAge()
指针类型调用者指针被解引用为值,上例中,p2.GetAge()实际上是 (*p1).GetAge()实际上也是“传值”,方法里的操作会影响到调用者,类似于指针传参,拷贝了一份指针

如果实现了接收者是值类型的方法,会隐含地也实现了接收者是指针类型的方法。

如果方法的接收者是值类型,无论调用者是对象还是对象指针,修改的都是对象的副本,不影响调用者;如果方法的接收者是指针类型,则调用者修改的是指针指向的对象本身。

通常我们使用指针作为方法的接收者的理由:

  • 使用指针方法能够修改接收者指向的值。

  • 可以避免在每次调用方法时复制该值,在值的类型为大型结构体时,这样做会更加高效。

因而呢,我们是使用值接收者还是指针接收者,不是由该方法是否修改了调用者(也就是接收者)来决定,而是应该基于该类型的本质。

如果类型具备“原始的本质”,也就是说它的成员都是由 Go 语言里内置的原始类型,如字符串,整型值等,那就定义值接收者类型的方法。像内置的引用类型,如 slice,map,interface,channel,这些类型比较特殊,声明他们的时候,实际上是创建了一个 header, 对于他们也是直接定义值接收者类型的方法。这样,调用函数时,是直接 copy 了这些类型的 header,而 header 本身就是为复制设计的。

如果类型具备非原始的本质,不能被安全地复制,这种类型总是应该被共享,那就定义指针接收者的方法。比如 go 源码里的文件结构体(struct File)就不应该被复制,应该只有一份实体。

接口值的零值是指动态类型和动态值都为 nil。当仅且当这两部分的值都为 nil 的情况下,这个接口值就才会被认为 接口值 == nil。

107. 编写函数 walk(x interface{}, fn func(string)),参数为结构体 x,并对 x 中的所有字符串字段调用 fn 函数。

使用反射区实现:
func walk(x interface{}, fn func(input string)) {
	val := getValue(x)

	walkValue := func(value reflect.Value) {
		walk(value.Interface(), fn)
	}

	switch val.Kind() {
	case reflect.String:
		fn(val.String())
	case reflect.Struct:
		for i := 0; i< val.NumField(); i++ {
			walkValue(val.Field(i))
		}
	case reflect.Slice, reflect.Array:
		for i:= 0; i<val.Len(); i++ {
			walkValue(val.Index(i))
		}
	case reflect.Map:
		for _, key := range val.MapKeys() {
			walkValue(val.MapIndex(key))
		}
	}
}

func getValue(x interface{}) reflect.Value {
	val := reflect.ValueOf(x)

	if val.Kind() == reflect.Ptr {
		val = val.Elem()
	}

	return val
}

func testWalk(t *testing.T){
	cases := []struct{
		Name string
		Input interface{}
		ExpectedCalls []string
	}{
		{
			"Struct with one string field",
			struct {
				Name string
			}{ "Chris"},
			[]string{"Chris"},
		},
	}

	for _,test :=range cases{
		t.Run(test.Name, func(t *testing.T) {
			var got []string
			walk(test.Input, func(input string) {
				got = append(got,input)
			})

			if !reflect.DeepEqual(got,test.ExpectedCalls){
				fmt.Println("not expected")
			}
		})
	}
}

108. Mysql索引.

mysql的索引分为单列索引(主键索引,唯索引,普通索引)和组合索引.

  • 单列索引:一个索引只包含一个列,一个表可以有多个单列索引.

  • 组合索引:一个组合索引包含两个或两个以上的列.

如下一个table表:

CREATE TABLE `award` (
   `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '用户id',
   `activity_id` varchar(100) NOT NULL DEFAULT '' COMMENT '活动场景id',
   `nickname` varchar(12) NOT NULL DEFAULT '' COMMENT '用户昵称',
   `is_awarded` tinyint(1) NOT NULL DEFAULT 0 COMMENT '用户是否领奖',
   `award_time` int(11) NOT NULL DEFAULT 0 COMMENT '领奖时间',
   `account` varchar(12) NOT NULL DEFAULT '' COMMENT '帐号',
   `password` char(32) NOT NULL DEFAULT '' COMMENT '密码',
   `message` varchar(255) NOT NULL DEFAULT '' COMMENT '获奖信息',
   `created_time` int(11) NOT NULL DEFAULT 0 COMMENT '创建时间',
   `updated_time` int(11) NOT NULL DEFAULT 0 COMMENT '更新时间',
   PRIMARY KEY (`id`)
 ) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8 COMMENT='获奖信息表';

索引的创建:

1.单列索引

  • 普通索引.

普通索引是最基本的索引,其sql格式是:

CREATE INDEX IndexName ON `TableName`(`字段名`(length)) 或者 ALTER TABLE TableName ADD INDEX IndexName(`字段名`(length))

第一种方式 :

CREATE INDEX account_Index ON `award`(`account`);

第二种方式:

ALTER TABLE award ADD INDEX account_Index(`account`)

如果是CHAR,VARCHAR,类型,length可以小于字段的实际长度,如果是BLOB和TEXT类型就必须指定长度.

  • 唯一索引.

唯一索引与普通索引类似,但是不同的是唯一索引要求所有的类的值是唯一的,这一点和主键索引一样.但是唯一索引允许有空值.

唯一索引的sql格式是:

CREATE UNIQUE INDEX IndexName ON `TableName`(`字段名`(length)); 或者 ALTER TABLE TableName ADD UNIQUE (column_list)  ```

```sql
 CREATE UNIQUE INDEX account_UNIQUE_Index ON `award`(`account`);
  • 主键索引

主键索引不允许有空值,(在B+TREE中的InnoDB引擎中,主键索引起到了至关重要的地位).

主键索引建立的规则是 int优于varchar,一般在建表的时候创建,最好是与表的其他字段不相关的列或者是业务不相关的列.一般会设为 int 而且是 AUTO_INCREMENT自增类型的.

2.组合索引

一个表中含有多个单列索引不代表是组合索引,通常来讲 组合索引是:包含多个字段但是只有索引名称.

组合索引sql格式是:

CREATE INDEX IndexName On `TableName`(`字段名`(length),`字段名`(length),...);

实现的sql是:

CREATE INDEX nickname_account_createdTime_Index ON `award`(`nickname`, `account`, `created_time`);

通过下面的sql我们可以查看到刚刚建表的具体的索引:

SHOW INDEX from `award`

 

如果建立了组合索引(nickname_account_createdTime_Index),通过上图可以看到,实际包含的是3个索引 (nickname) (nickname,account)(nickname,account,created_time).

在使用查询的时候遵循mysql组合索引的"最左前缀",下面我们来分析一下 什么是最左前缀:及索引where时的条件要按照建立索引的时候字段的排序方式.

(1)、不按索引最左列开始查询(多列索引) 例如index(‘c1’, ‘c2’, ‘c3’) where ‘c2’ = ‘keke’ 不使用索引,where c2 = keke and c3=jame 不能使用索引.

(2)、查询中某个列有范围查询,则其右边的所有列都无法使用查询(多列查询)

Where c1= ‘andy’ and c2 like = ‘ke%’ and c3=’jame’ 改查询只会使用索引中的前两列,因为like是范围查询.

(3)、不能跳过某个字段来进行查询,这样利用不到索引.

比如sql 是explain, select * from award where nickname > 'heln' and account = '120839' and created_time = 1449567822; 那么这时候他使用不到其组合索引.

因为我的索引是 (nickname, account, created_time),如果第一个字段出现 范围符号的查找,那么将不会用到索引,如果我是第二个或者第三个字段使用范围符号的查找,那么他会利用索引,利用的索引是(nickname),

因为上面说了建立组合索引(nickname, account, created_time), 会出现三个索引.

  1. 全文索引

文本字段上(text)如果建立的是普通索引,那么只有对文本的字段内容前面的字符进行索引,其字符大小根据索引建立索引时申明的大小来规定.

如果文本中出现多个一样的字符,而且需要查找的话,那么其条件只能是 where column lick '%xxxx%' 这样做会让索引失效.这个时候全文索引就开始起作用了.

全文索引sql格式是:

ALTER TABLE tablename ADD FULLTEXT(column1, column2)

通过全文索引,就可以用SELECT查询命令去检索那些包含着一个或多个给定单词的数据记录了.

SELECT * FROM tablename WHERE MATCH(column1, column2) AGAINST(‘xxx′, ‘keke′, ‘jame′)

这样就可以把column1和column2字段里有keke、jame和andy的数据记录全部查询出来。

索引的删除:

删除索引的sql格式:

DORP INDEX IndexName ON `TableName`

使用索引的优点:

1.可以通过建立唯一索引或者主键索引,保证数据库表中每一行数据的唯一性.

2.建立索引可以大大提高检索的数据,以及减少表的检索行数.

3.在表连接的连接条件 可以加速表与表直接的相连.

4.在分组和排序字句进行数据检索,可以减少查询时间中 分组 和 排序时所消耗的时间(数据库的记录会重新排序).

5.建立索引,在查询中使用索引 可以提高性能.

使用索引的缺点:

1.在创建索引和维护索引 会耗费时间,随着数据量的增加而增加.

2.索引文件会占用物理空间,除了数据表需要占用物理空间之外,每一个索引还会占用一定的物理空间.

3.当对表的数据进行 INSERT,UPDATE,DELETE 的时候,索引也要动态的维护,这样就会降低数据的维护速度,(建立索引会占用磁盘空间的索引文件。
一般情况这个问题不太严重,但如果你在一个大表上创建了多种组合索引,索引文件的会膨胀很快)。

使用索引需要注意的地方:

在建立索引的时候应该考虑索引应该建立在数据库表中的某些列上面 哪一些索引需要建立,哪一些所以是多余的.

  1. 在经常需要搜索的列上,可以加快索引的速度.

  2. 主键列上可以确保列的唯一性.

  3. 在表与表的而连接条件上加上索引,可以加快连接查询的速度.

  4. 在经常需要排序(order by),分组(group by)和的distinct 列上加索引 可以加快排序查询的时间,(单独order by 用不了索引,索引考虑加where 或加limit).

  5. 在一些where 之后的 <, <=, >, >= BETWEEN IN 以及某个情况下的like 建立字段的索引(B-TREE).

  6. like语句的 如果你对nickname字段建立了一个索引.当查询的时候的语句是 nickname lick '%ABC%' 那么这个索引讲不会起到作用.而nickname lick 'ABC%' 那么将可以用到索引.

  7. 索引不会包含NULL列,如果列中包含NULL值都将不会被包含在索引中,复合索引中如果有一列含有NULL值那么这个组合索引都将失效,一般需要给默认值0或者 ' '字符串.

  8. 使用短索引,如果你的一个字段是Char(32)或者int(32),在创建索引的时候指定前缀长度 比如前10个字符 (前提是多数值是唯一的..)那么短索引可以提高查询速度,并且可以减少磁盘的空间,也可以减少I/0操作.

  9. 不要在列上进行运算,这样会使得mysql索引失效,也会进行全表扫描

  10. 选择越小的数据类型越好,因为通常越小的数据类型通常在磁盘,内存,cpu,缓存中 占用的空间很少,处理起来更快

不需要创建索引的情况包括:

  1. 查询中很少使用到的列 不应该创建索引,如果建立了索引然而还会降低mysql的性能和增大了空间需求.
  2. 很少数据的列也不应该建立索引,比如 一个性别字段 0或者1,在查询中,结果集的数据占了表中数据行的比例比较大,mysql需要扫描的行数很多,增加索引,并不能提高效率.
  3. 定义为text和image和bit数据类型的列不应该增加索引.
  4. 当表的修改(UPDATE,INSERT,DELETE)操作远远大于检索(SELECT)操作时不应该创建索引,这两个操作是互斥的关系.

109.设计一个连续签到的任务,连续签到7天,中间不能中断,如果中断了,就重新从第一天开始签到,连续签到三天奖励,连续签到7天奖励.

110.kubernetes中不同的Node中的pod如何进行通信?

111. Reids的特点?

Redis本质上是一个Key-Value类型的内存数据库,很像memcached,整个数据库统统加载在内存当中进行操作,定期通过异步操作把数据库数据flush到硬盘上进行保存。

因为是纯内存操作,Redis的性能非常出色,每秒可以处理超过 10万次读写操作,是已知性能最快的Key-Value DB。

Redis的出色之处不仅仅是性能,Redis最大的魅力是支持保存多种数据结构,此外单个value的最大限制是1GB,不像 memcached只能保存1MB的数据,因此Redis可以用来实现很多有用的功能。

比方说用他的List来做FIFO双向链表,实现一个轻量级的高性能消息队列服务,用他的Set可以做高性能的tag系统等等。另外Redis也可以对存入的Key-Value设置expire时间,因此也可以被当作一 个功能加强版的memcached来用。

Redis的主要缺点是数据库容量受到物理内存的限制,不能用作海量数据的高性能读写,因此Redis适合的场景主要局限在较小数据量的高性能操作和运算上。

  1. 使用redis有哪些好处?
  • 速度快,因为数据存在内存中,类似于HashMap,HashMap的优势就是查找和操作的时间复杂度都是O(1).
  • 支持丰富数据类型,支持string,list,set,sorted set,hash.
  • 支持事务,操作都是原子性,所谓的原子性就是对数据的更改要么全部执行,要么全部不执行.
  • 丰富的特性:可用于缓存,消息,按key设置过期时间,过期后将会自动删除.

113. redis事物的了解CAS(check-and-set 操作实现乐观锁 )

和众多其它数据库一样,Redis作为NoSQL数据库也同样提供了事务机制。在Redis中,MULTI/EXEC/DISCARD/WATCH这四个命令是我们实现事务的基石。 相信对有关系型数据库开发经验的开发者而言这一概念并不陌生,即便如此,我们还是会简要的列出 Redis中事务的实现特征 :

1.在事务中的所有命令都将会被串行化的顺序执行,事务执行期间,Redis不会再为其它客户端的请求提供任何服务,从而保证了事物中的所有命令被原子的执行。

2.和关系型数据库中的事务相比,在Redis事务中如果有某一条命令执行失败,其后的命令仍然会被继续执行。

3.我们可以通过MULTI命令开启一个事务,有关系型数据库开发经验的人可以将其理解为"BEGIN TRANSACTION"语句。在该语句之后执行的命令都将被视为事务之内的操作,最后我们可以通过执行EXEC/DISCARD命令来提交/回滚该事务内的所有操作。这两个Redis命令可被视为等同于关系型数据库中的COMMIT/ROLLBACK语句。

4.在事务开启之前,如果客户端与服务器之间出现通讯故障并导致网络断开,其后所有待执行的语句都将不会被服务器执行。然而如果网络中断事件是发生在客户端执行EXEC命令之后,那么该事务中的所有命令都会被服务器执行。

5.当使用Append-Only模式时,Redis会通过调用系统函数write将该事务内的所有写操作在本次调用中全部写入磁盘。然而如果在写入的过程中出现系统崩溃,如电源故障导致的宕机,那么此时也许只有部分数据被写入到磁盘,而另外一部分数据却已经丢失。

Redis服务器会在重新启动时执行一系列必要的一致性检测,一旦发现类似问题,就会立即退出并给出相应的错误提示。

此时,我们就要充分利用Redis工具包中提供的redis-check-aof工具,该工具可以帮助我们定位到数据不一致的错误,并将已经写入的部分数据进行回滚。修复之后我们就可以再次重新启动Redis服务器了。

  1. redis持久化的几种方式?
  • 快照(snapshots)

缺省情况情况下,Redis把数据快照存放在磁盘上的二进制文件中,文件名为dump.rdb。你可以配置Redis的持久化策略,例如数据集中每N秒钟有超过M次更新,就将数据写入磁盘;或者你可以手工调用命令SAVE或BGSAVE。

原理: Redis forks, 子进程开始将数据写到临时RDB文件中。当子进程完成写RDB文件,用新文件替换老文件。这种方式可以使Redis使用copy-on-write技术。

  • AOF

快照模式并不十分健壮,当系统停止,或者无意中Redis被kill掉,最后写入Redis的数据就会丢失。

这对某些应用也许不是大问题,但对于要求高可靠性的应用来说,Redis就不是一个合适的选择。Append-only文件模式是另一种选择。你可以在配置文件中打开AOF模式.

  • 虚拟内存方式

当key很小而value很大时,使用VM的效果会比较好.因为这样节约的内存比较大.

当key不小时,可以考虑使用一些非常方法将很大的key变成很大的value,比如你可以考虑将key,value组合成一个新的value. vm-max-threads这个参数,可以设置访问swap文件的线程数,设置最好不要超过机器的核数,如果设置为0,那么所有对swap文件的操作都是串行的.可能会造成比较长时间的延迟,但是对数据完整性有很好的保证.

自测试的时候发现用虚拟内存性能也不错。如果数据量很大,可以考虑分布式或者其他数据库。

  1. 已知 sqrt (2)约等于 1.414,要求不用数学库,求 sqrt (2)精确到小数点后 10 位。
  2. 如何实现一个高效的单向链表逆序输出?
  3. 给定一个二叉搜索树(BST),找到树中第 K 小的节点。

主要考察,基础数据结构的理解和编码能力,递归使用.

       5
      / \
     3   6
    / \
   2   4
  /
 1

说明:保证输入的 K 满足 1<=K<=(节点数目)

树相关的题目,第一眼就想到递归求解,左右子树分别遍历。联想到二叉搜索树的性质,root 大于左子树,小于右子树,如果左子树的节点数目等于 K-1,那么 root 就是结果,否则如果左子树节点数目小于 K-1,那么结果必然在右子树,否则就在左子树。因此在搜索的时候同时返回节点数目,跟 K 做对比,就能得出结果了。

  1. 关于 epoll 和 select 的区别?
  • epoll 和 select 都是 I/O 多路复用的技术,都可以实现同时监听多个 I/O 事件的状态。
  • epoll 相比 select 效率更高,主要是基于其操作系统支持的I/O事件通知机制,而 select 是基于轮询机制。
  • epoll 支持水平触发和边沿触发两种模式。
  1. 从 innodb 的索引结构分析,为什么索引的 key 长度不能太长?

key 太长会导致一个页当中能够存放的 key 的数目变少,间接导致索引树的页数目变多,索引层次增加,从而影响整体查询变更的效率。 120. MySQL 的数据如何恢复到任意时间点?

恢复到任意时间点以定时的做全量备份,以及备份增量的 binlog 日志为前提。恢复到任意时间点首先将全量备份恢复之后,再此基础上回放增加的 binlog 直至指定的时间点。

121. 输入 ping IP 后敲回车,发包前会发生什么?

首先根据目的IP和路由表决定走哪个网卡,再根据网卡的子网掩码地址判断目的IP是否在子网内。如果不在则会通过arp缓存查询IP的网卡地址,不存在的话会通过广播询问目的IP的mac地址,得到后就开始发包了,同时mac地址也会被arp缓存起来。

122. 给定一个二叉树,判断其是否是一个有效的二叉搜索树。

什么是二叉树(Binary Tree)?

每个结点至多拥有两棵子树的树结构(即二叉树中不存在度大于2的结点)。并且,二叉树的子树有左右之分,其次序不能任意颠倒。通常子树被称作“左子树”(left subtree)和“右子树”(right subtree)。二叉树常被用于实现二叉查找树和二叉堆。

上面概念中提到了“度”的概念,“度”其实就是某个节点子节点的数量。如果某个节点的子节点数量为1,则该节点的度为1,如果有8个子节点,则度为8,以此类推。

假设一个二叉搜索树具有如下特征:

  • 节点的左子树只包含小于当前节点的数。
  • 节点的右子树只包含大于当前节点的数。
  • 所有左子树和右子树自身必须也是二叉搜索树。
示例 1:
输入:
   2
  / \
 1   3
输出: true
示例 2:
输入:
     5
    / \
   1   4
  / \
 3   6
输出: false
解释: 输入为: [5,1,4,null,null,3,6]。
根节点的值为 5 ,但是其右子节点值为 4 。

解法一,直接按照定义比较大小,比 root 节点小的都在左边,比 root 节点大的都在右边:

type TreeNode struct {
      Val int
      Left *TreeNode
      Right *TreeNode
}

func isValidBST(root *TreeNode) bool {
	return isValid(root, math.MinInt64, math.MaxInt64)
}

func isValid(root *TreeNode, min, max int) bool {
	if root == nil {
		return true
	}
	if root.Val <= min {
		return false
	}
	if root.Val >= max {
		return false
	}
	return isValid(root.Left, min, root.Val) && isValid(root.Right, root.Val, max)
}
  1. Go调度相关的整个过程熟悉吗?
  • 什么时候会阻塞线程?
  • Go的对象在内存中是怎样的?
  • Go的内存分配是怎样的?
  • 栈的内存是怎么分配的?
  • GC是怎样的?
  • GC怎么帮我们回收对象?
  • Go的GC会不会漏掉对象或者回收还在⽤的对象?
  • Go GC什么时候开始?
  • Go GC啥时候结束?
  • Go GC会不会太慢, 跟不上内存分配的速度?
  • Go GC会不会暂停我们的应用? 暂停多久? 影不影响我的请求?

114. Mysql分区表的数量限制和需要注意的地方?

115. Mysql为什么使用B+树?而不是使用平衡二叉树.

主要是查询效率高,O(logN),可以充分利用磁盘预读的特性,多叉树,深度小,叶子结点有序且存储数据.

AVL树是带有平衡条件的二叉查找树,一般是用平衡因子差值判断是否平衡并通过旋转来实现平衡,左右子树树高不超过1,和红黑树相比,它是严格的平衡二叉树,平衡条件必须满足(所有节点的左右子树高度差不超过1)。

不管我们是执行插入还是删除操作,只要不满足上面的条件,就要通过旋转来保持平衡,而旋转是非常耗时的,由此我们可以知道AVL树适合用于插入删除次数比较少,但查找多的情况。 平衡二叉树是基于二分法的策略提高数据的查找速度的二叉树的数据结构;

局限性:

由于维护这种高度平衡所付出的代价比从中获得的效率收益还大,故而实际的应用不多,更多的地方是用追求局部而不是非常严格整体平衡的红黑树。当然,如果应用场景中对插入删除不频繁,只是对查找要求较高,那么AVL还是较优于红黑树。

B+Tree是在B-Tree(不要读成B减树,而是B树)基础上的一种优化,使其更适合实现外存储索引结构,InnoDB存储引擎就是用B+Tree实现其索引结构。

B-Tree中每个节点中不仅包含数据的key值,还有data值。而每一个页的存储空间是有限的,如果data数据较大时将会导致每个节点(即一个页)能存储的key的数量很小,当存储的数据量很大时同样会导致B-Tree的深度较大,增大查询时的磁盘I/O次数,进而影响查询效率。在B+Tree中,所有数据记录节点都是按照键值大小顺序存放在同一层的叶子节点上,而非叶子节点上只存储key值信息,这样可以大大加大每个节点存储的key值数量,降低B+Tree的高度。

B+树,仍是m叉搜索树,在B树的基础上,做了一些改进:

  • 非叶子节点不再存储数据,数据只存储在同一层的叶子节点上.
  • 叶子之间,增加了链表,获取所有节点,不再需要中序遍历.

这些改进让B+树比B树有更优的特性:

  • (范围查找,定位min与max之后,中间叶子节点,就是结果集,不用中序回溯;
  • 叶子节点存储实际记录行,记录行相对比较紧密的存储,适合大数据量磁盘存储;非叶子节点存储记录的PK,用于查询加速,适合内存存储;
  • 非叶子节点,不存储实际记录,而只存储记录的KEY的话,那么在相同内存的情况下,B+树能够存储更多索引;

因此可以了解到B+树是最适合做数据库索引的.

116. Data Race问题怎么解决?能不能不加锁解决这个问题?

定义:①多个线程对于同一个变量、②同时地、③进行读/写操作的现象并且④至少有一个线程进行写操作。(也就是说,如果所有线程都是只进行读操作,那么将不构成数据争用)

结果:如果发生了数据争用,读取该变量时得到的值将变得不可知,使得该多线程程序的运行结果将完全不可预测,可能直接崩溃。

解决方式:对于有可能被多个线程同时访问的变量使用排他访问控制,具体方法包括使用mutex(互斥量)和monitor(监视器),或者使用atomic变量。

相对于数据争用(data race),竞态条件(race condition)指的是更加高层次的更加复杂的现象,一般需要在设计并行程序时进行细致入微的分析,才能确定。

定义:受各线程上代码执行的顺序和时机的影响,程序的运行结果产生(预料之外)的变化。

后果:如果存在竞态条件(race condition),多次运行程序对于同一个输入将会有不同的结果,但结果并非完全不可预测,它将由输入数据和各线程的执行顺序共同决定。

如何预防:竞态条件产生的原因很多是对于同一个资源的一系列连续操作并不是原子性的,也就是说有可能在执行的中途被其他线程抢占,同时这个“其他线程”刚好也要访问这个资源。解决方法通常是:将这一系列操作作为一个critical section(临界区)。

117. 解决hash冲突的办法?

解决hash冲突的办法 有四种:

  • 开放定址法(线性探测再散列,二次探测再散列,伪随机探测再散列)
  • 再哈希法
  • 链地址法
  • 建立一个公共溢出区

118. 为什么使用通信来共享内存?

不要通过共享内存来通信,我们应该使用通信来共享内存,这是一句使用 Go 语言编程的人经常能听到的观点,然而我们可能从来都没有仔细地思考过 Go 语言为什么鼓励我们遵循这一设计哲学.

发送消息和共享内存这两种方式其实是用来传递信息的不同方式,但是它们两者有着不同的抽象层级,发送消息是一种相对高级的抽象,但是不同语言在实现这一机制时也都会使用操作系统提供的锁机制来实现,共享内存这种最原始和最本质的信息传递方式就是使用锁这种并发机制实现的。

更为高级和抽象的信息传递方式其实也只是对低抽象级别接口的组合和封装,Go 语言中的 Channel 就提供了 Goroutine 之间用于传递信息的方式,它在内部实现时就广泛用到了共享内存和锁,通过对两者进行的组合提供了更高级的同步机制。

首先,使用发送消息的方式替代共享内存也能够帮助我们减少多个模块之间的耦合,假设我们使用共享内存的方式在多个 Goroutine 之间传递信息,每个 Goroutine 都可能是资源的生产者和消费者,它们需要在读取或者写入数据时先获取保护该资源的互斥锁。

然而我们使用发送消息的方式却可以将多个线程或者协程解耦,以前需要依赖同一个片内存的多个线程,现在可以成为消息的生产者和消费者,多个线程也不需要自己手动处理资源的获取和释放,其中 Go 语言实现的 CSP 机制通过引入 Channel 来解耦 Goroutine

其次,在通常情况下,并发编程带来的很多问题都是因为没有正确实现访问共享编程的逻辑,而 Go 语言却鼓励我们将需要共享的变量传入 Channel 中,所有被共享的变量并不会同时被多个活跃的 Goroutine 访问,这种方式可以保证在同一时间只有一个 Goroutine 能够访问对应的值,所以数据冲突和线程竞争的问题在设计上就不可能出现。

不要通过共享内存来通信,我们应该通过通信来共享内存,Go 语言鼓励我们使用这种方式设计能够处理高并发请求的程序。

Go 语言在实现上通过 Channel 保证被共享的变量不会同时被多个活跃的 Goroutine 访问,一旦某个消息被发送到了 Channel 中,我们就失去了当前消息的控制权,作为接受者的 Goroutine 在收到这条消息之后就可以根据该消息进行一些计算任务;从这个过程来看,消息在被发送前只由发送方进行访问,在发送之后仅可被唯一的接受者访问,所以从这个设计上来看我们就避免了线程竞争。

因此,Go 语言并发模型的设计深受 CSP 模型的影响,从而使用通信的方式来共享内存。

  1. 首先,使用发送消息来同步信息相比于直接使用共享内存和互斥锁是一种更高级的抽象,使用更高级的抽象能够为我们在程序设计上提供更好的封装,让程序的逻辑更加清晰.
  2. 其次,消息发送在解耦方面与共享内存相比也有一定优势,我们可以将线程的职责分成生产者和消费者,并通过消息传递的方式将它们解耦,不需要再依赖共享内存.
  3. 最后,Go 语言选择消息发送的方式,通过保证同一时间只有一个活跃的线程能够访问数据,能够从设计上天然地避免线程竞争和数据冲突的问题.

119. Go的调度为什么说是轻量的?

首先我们先看下进程和线程还有协程之间的区别:

  • 进程

 计算机的操作系统模式是一种多任务系统,操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用程序都以进程(process)的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。CPU由操作系统一进行分配,每个进程根据进程的优先级的高低都有机会得到CPU,但是如果允许时间超出了一定的时间,操作系统会暂停该进程,将CPU资源分配给其他等待的进程。这种CPU的分配方式即所谓的抢占式,操作系统可以强制剥夺CPU资源并且分配给它认为目前最需要的进程。如果操作系统分配给每个进程的时间都很短,即CPU在多个进程间快速地切换,从而造成了很多进程都在同时运行的假象。

  • 线程

线程有时被称为轻量级进程(Lightweight Process),是程序执行流的最小单元,一个标准的线程由线程ID,当前指令指针(PC)、寄存器集合和堆栈组成,通常意义上,一个进程u6709一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源(如打开文件和信号)。

  • 协程

协程(coroutine)是Go语言中的轻量级线程实现,由Go运行时(runtime)管理。

进程、线程、协程的关系和区别:

  • 进程拥有自己独立的堆和栈,既不共享堆,亦不共享栈,进程由操作系统调度。

  • 线程拥有自己独立的栈和共享的堆,共享堆,不共享栈,线程亦由操作系统调度(标准线程是的)。

  • 协程和线程一样共享堆,不共享栈,协程由程序开发者在协程的代码里显示调度。

为什么协程比线程轻量?

a. go协程调用跟切换比线程效率高

线程并发执行流程: 线程是内核对外提供的服务,应用程序可以通过系统调用让内核启动线程,由内核来负责线程调度和切换。线程在等待IO操作时线程变为unrunnable状态会触发上下文切换。现代操作系统一般都采用抢占式调度,上下文切换一般发生在时钟中断和系统调用返回前,调度器计算当前线程的时间片,如果需要切换就从运行队列中选出一个目标线程,保存当前线程的环境,并且恢复目标线程的运行环境,最典型的就是切换ESP指向目标线程内核堆栈,将EIP指向目标线程上次被调度出时的指令地址。

go协程并发执行流程:不依赖操作系统和其提供的线程,golang自己实现的CSP并发模型实现:M, P, G .go协程也叫用户态线程,协程之间的切换发生在用户态。在用户态没有时钟中断,系统调用等机制,因此效率高

b. go协程占用内存少

执行go协程只需要极少的栈内存(大概是4~5KB),默认情况下,线程栈的大小为1MB。goroutine就是一段代码,一个函数入口,以及在堆上为其分配的一个堆栈。所以它非常廉价,我们可以很轻松的创建上万个goroutine,但它们并不是被操作系统所调度执行。

120.Go调度都发生了什么?

Golang Runtime是Go语言运行所需要的基础设施.

  • 协程调度, 内存分配, GC;
  • 操作系统及CPU相关的操作的封装(信号处理, 系统调用, 寄存器操作, 原子操作等), CGO;
  • pprof, trace, race检测的支持;
  • map, channel, string等内置类型及反射的实现.

 

121.Go的网络和锁会不会阻塞线程?

我们先来看下什么是线程阻塞.

在某一时刻某一个线程在运行一段代码的时候,这时候另一个线程也需要运行,但是在运行过程中的那个线程执行完成之前,另一个线程是无法获取到CPU执行权的(调用sleep方法是进入到睡眠暂停状态,但是CPU执行权并没有交出去,而调用wait方法则是将CPU执行权交给另一个线程),这个时候就会造成线程阻塞。

在go中,协程(coroutine)是Go语言中的轻量级线程实现,由Go运行时(runtime)管理。因此一个线程下可能会有很多个协程,因此不会阻塞线程.

Golang面试参考

 

相关推荐
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页