M代表一个内核线程,也可以称为一个工作线程。goroutine就是跑在M之上的。(两个M如果运行在一个CPU上就是并发,如果运行在不同CPU就是并行。)
P 代表着处理器,或是程序执行上下文,将等待执行的G与M对接。Go的运行时系统会适时地让P与不同的M建立或断开关联,以使P中的那些可运行的G能够及时获得运行时机;
G代表协程(是一个轻量级的执行线程),可以有多个;
(go采用了基于消息并发模型的方式。它将基于CSP模型的并发编程内置到了语言中,通过一个go关键字就可以轻易地启动一个Goroutine,而且在Goroutine之间是共享内存的。)
MPG的调度:
Go Goroutine采用的是半抢占式的协作调度,只有在G0发生阻塞时或者占用M超过10ms才会导致调度,否则会依次执行P绑定的其他G
当G0阻塞时,调度器会将P与当前的M0和G0解绑,同时去空闲M列表中找新的M,如果没有则创建M1,绑定P,顺序执行P下的G。
当G0阻塞结束后,调度器会到空闲P列表中为M0找空闲可绑定的P,如果恰巧有P,则继续执行G0。如果没有可用的P,M0被放入空闲列表,等待调度给需要的G;G0被放入可运行的G列表,列表中的G会经由调度再次放入某个P的可运行G队列。(M1和M0可以是并行的)
当发生上下文切换时,先要把前一个任务的上下文保存起来,上下文包括:cpu寄存器和程序计数器(PC指针)。
协程的上下文信息会存到P中,进程/线程的上下文信息会存到内核当中。
2023.3.31补充:
每一个P包括: runnext列表(只存放一个g) 和本地runq列表(容量一般为256);每当一个新的g进入到P,runnext列表里的g会被移动至本地runq中,当本地runq已经满了,会将本地runq列表里一半g(1-128)移至全局runq列表。M上g的运行顺序为:runnext —> 本地runq—>全局runq,需要注意的是,为了避免所有p繁忙,全局runq列表等待过久的情况,会有将全局runq穿插在本地runq执行的调度策略。
Context 为同一任务的多个 goroutine 之间提供了退出信号通知和元数据传递的功能。
Context 是用来管理goroutine的,主要有两个作用:
gin框架中每个请求的处理都会开启一个协程,协程易于创建,也易于泄露,context(也叫做上下文)的出现就是为了管理协程。
所以 Context 模式声明一些接口,显式传递给子函数,子函数的 goroutine 主动检查 Context 的状态并作出正确的响应。
Context 包允许传递一个 "context" 到程序。 Context 可以是超时或截止日期(deadline)或通道,来指示停止运行和返回。
Go 语言中的每一个请求的都是通过一个单独的 Goroutine 进行处理的,我们可能会创建多个 Goroutine 来处理一次请求,而Context的主要作用就是在不同的 Goroutine 之间同步请求特定的数据、取消信号以及处理请求的截止日期。
每一个Context都会从最顶层的 Goroutine 一层一层传递到最下层,这也是 Golang 中上下文最常见的使用方式,如果没有Contex,当上层执行的操作出现错误时,下层其实不会收到错误而是会继续执行下去。但是当我们正确地使用Contex时,就可以在下层及时停掉无用的工作减少额外资源的消耗。
这其实就是 Golang 中上下文的最大作用,在不同 Goroutine 之间对信号进行同步避免对计算资源的浪费,与此同时Contex还能携带以请求为作用域的键值对信息。
区别:
array定长,slice不定长。
数组是值类型,切片是一个引用类型。但他们的传递方法都是值传递。
联系:
slice是基于数组类型做的一层封装。它非常灵活,支持自动扩容。
一个slice由三个部分构成:指针、长度和容量。指针指向第一个slice元素对应的底层数组元素的地址。长度对应slice中元素的数目;长度不能超过容量,容量一般是从slice的开始位置到底层数组的结尾位置。
Go 中的并发性是以 goroutine(独立活动)和 channel(用于通信)的形式实现的。处理 goroutine 时,程序员需要小心翼翼地避免泄露。如果一个协程永远堵塞在 I/O 上(例如 channel 通信),或者是陷入死循环,协程就会一直消耗资源,永远无法结束,这就造成了goroutine 泄露。goroutine 泄露后,程序会使用比实际需要更多的内存,或者最终耗尽内存,从而导致崩溃。
泄露的原因大多集中在:
Goroutine 内正在进行 channel/mutex 等读写操作,但由于逻辑问题,某些情况下会被一直阻塞。
Goroutine 内的业务逻辑进入死循环,资源一直无法释放。
Goroutine 内的业务逻辑进入长时间等待,有不断新增的 Goroutine 进入等待。
func main() {
for i := 0; i < 4; i++ {
queryAll()
fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
}
}
func queryAll() int {
ch := make(chan int)
for i := 0; i < 3; i++ {
go func() { ch <- query() }()
}
return <-ch
}
func query() int {
n := rand.Intn(100)
time.Sleep(time.Duration(n) * time.Millisecond)
return n
}
输出结果:
goroutines: 3
goroutines: 5
goroutines: 7
goroutines: 9
输出的 goroutines 数量是在不断增加的,每次多 2 个。也就是每调用一次,都会泄露 Goroutine。
原因在于 channel 均已经发送了(每次发送 3 个),但是在接收端并没有接收完全(只返回 1 个 ch),所以诱发了 Goroutine 泄露。
func main() {
defer func() {
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
var ch chan struct{}
go func() {
ch <- struct{}{}
}()
time.Sleep(time.Second)
}
输出结果:
goroutines: 2
channel 接收了值,但是不发送的话,同样会造成阻塞。
func main() {
defer func() {
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
var ch chan int
go func() {
<-ch
}()
time.Sleep(time.Second)
}
输出结果:
goroutines: 2
channel 如果忘记初始化,那么无论是读,还是写操作,都会造成阻塞。
正确的初始化:
ch := make(chan int)
go func() {
<-ch
}()
ch <- 0
time.Sleep(time.Second)
func main() {
for {
go func() {
_, err := http.Get("https://www.xxx.com/")
if err != nil {
fmt.Printf("http.Get err: %v\n", err)
}
// do something...
}()
time.Sleep(time.Second * 1)
fmt.Println("goroutines: ", runtime.NumGoroutine())
}
}
输出结果:
goroutines: 5
goroutines: 9
goroutines: 13
goroutines: 17
goroutines: 21
goroutines: 25
...
在应用程序中去调用第三方服务的接口时,有时候会很慢,久久不返回响应结果。
而Go 语言中默认的http.Client是没有设置超时时间的。
因此就会导致一直阻塞,Goroutine 自然也就持续暴涨,不断泄露,最终占满资源,导致事故。
在 Go 工程中,我们一般建议至少对http.Client设置超时时间:
httpClient := http.Client{
Timeout: time.Second * 15,
}
并且要做限流、熔断等措施,以防突发流量造成依赖崩塌,造成P0事故。
func main() {
total := 0
defer func() {
time.Sleep(time.Second)
fmt.Println("total: ", total)
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
var mutex sync.Mutex
for i := 0; i < 10; i++ {
go func() {
mutex.Lock()
total += 1
}()
}
}
//正确写法
var mutex sync.Mutex
for i := 0; i < 10; i++ {
go func() {
mutex.Lock()
defer mutex.Unlock()
total += 1
}()
}
输出结果:
total: 1
goroutines: 10
在这个例子中,第一个互斥锁sync.Mutex加锁了,但是他可能在处理业务逻辑,又或是忘记unlock了。
因此导致后面的所有sync.Mutex想加锁,却因未释放又都阻塞住了。
func handle(v int) {
var wg sync.WaitGroup
wg.Add(5)
for i := 0; i < v; i++ {
fmt.Println("hello")
wg.Done()
}
wg.Wait()
}
func main() {
defer func() {
fmt.Println("goroutines: ", runtime.NumGoroutine())
}()
go handle(3)
time.Sleep(time.Second)
}
//正确写法
var wg sync.WaitGroup
for i := 0; i < v; i++ {
wg.Add(1)
defer wg.Done()
fmt.Println("hello")
}
wg.Wait()
在这个例子中,我们调用了同步编排sync.WaitGroup模拟了一遍我们会从外部传入循环遍历的控制变量。
但由于wg.Add的数量与wg.Done数量并不匹配,因此在调用wg.Wait方法后一直阻塞等待。
排查:
使用pprof排查
1. 使用pprof查看异常情况,
2.如果是goroutine数量异常多,说明goroutine泄露
3.具体查看goroutine阻塞在哪一部分代码
4. 查看单个goroutine的信息,用调用栈以及行数作为查找参数,随意找一个goroutine查看详细信息
内存逃逸是指在函数内部申请的临时变量,本应该在(栈)上面,但由于一些特殊原因,系统将其申请在堆上的情况。
程序变量会携带有一组校验数据,用来证明它的整个生命周期是否在运行时完全可知。如果变量通过了这些校验,它就可以在栈上分配。否则就说它逃逸了,必须在堆上分配。
在函数中申请一个新的对象:
如果分配 在栈中,则函数执行结束可自动将内存回收;
如果分配在堆中,则函数执行结束可交给GC(垃圾回收)处理;
针对第一条,可能放到堆上的情形:定义了一个很大的数组,需要申请的内存过大,超过了栈的存储能力。
能引起变量逃逸到堆上的典型情况:
一般程序遇到panic会崩溃,如果不捕获处理的话,线上程序遇到这情况就崩溃了,服务就挂掉了。
所以需要用recover捕获,然后执行重试或者跳过这个问题,总之不至于让程序挂掉。
但是如果开了一个协程,并且这个协程里面遇到了异常,而主协程里面的recover是捕获不到其他协程异常的,程序还是会挂掉。
这种简单一个go就开一个的协程就叫野生协程,如果里面有异常,程序就都挂了,正确的做法是每个协程里都要单独捕获它自己的异常,这样才不会挂掉。
野生groutine无法处理panic,很容易就会导致程序崩溃。
func main() {
GoHelp(func() {
fmt.Println("hello")
panic("error")
})
time.Sleep(10 * time.Second)
}
func GoHelp(f func()) {
go func() {
defer func() {
if err := recover(); err != nil {
fmt.Println("panic in GoHelp: ", err)
}
}()
f()
}()
}
//hello
//panic in GoHelp: goroutine error
被defer的函数在return 返回数据之后执行,这时刚好用来捕获函数抛出的panic。
当goroutine中引发panic时,此goroutine的所有defer都将会被执行。
运用此方法后,发现error被捕获,而进程并没有退出。
此时由野生goroutine引发的panic,就优雅的解决了。
内建的map不是线程(goroutine)安全的。
这是因为map 变量为引用(指针)类型变量,并发写时,多个协程同时操作一个内存,类似于多线程操作同一个资源会发生竞争关系,共享资源会遭到破坏。为了安全,系统会自动抛出错误。
如何解决:
主要思路是通过加锁保证每个协程不同步操作map。
它使用嵌入struct为map增加一个读写锁。
利用读写锁而不是Mutex可以进一步减少读写的时候因为锁带来的性能损失。
Golang Map并发处理机制(sync.Map)
进程是一个运行的应用程序。
进程是程序资源分配的最小单位。
进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位。
每个进程都有自己的独立内存空间,不同进程通过进程间通信来通信。
由于进程比较重量,占据独立的内存,所以上下文进程间的切换开销(栈、寄存器、虚拟内存、文件句柄等)比较大,但相对比较稳定安全。
一个进程下至少有一个线程,并且可以有多个线程。
线程是进程中的执行单元
线程是一种轻量级进程,是CPU调度的最小单位(线程是任务调度和执行的最小单位)。
同一进程内的线程共享资源和数据。
一个标准的线程由线程ID,PC指针,寄存器集合和堆栈组成。
线程是进程中的一个实体,是被系统独立调度和分派的基本单位,线程自己不拥有系统资源,只拥有一点在运行中必不可少的资源,但它可与同属于一个进程的其他线程共享进程所拥有的全部资源。
由于同一个进程里的线程是共享资源的,所以多线程间通信一般用全局变量就行(需要加锁防止资源竞争)
线程拥有自己独立的栈和共享的堆,共享堆,不共享栈。系统线程一般都有固定的栈内存(通常为2MB)。
进程和线程的切换由操作系统调度。
Go 的协程依赖于线程来进行。执行效率高、占用内存少、Goroutine之间是共享内存的。
协程拥有自己的寄存器上下文和栈。
协程调度切换时,将寄存器上下文和栈保存到其他地方(MPG中的P),在切回来的时候,恢复先前保存的寄存器上下文和栈,协程调度发生在用户态而非内核态,由runtime决定,上下文的切换非常快。
直接操作栈则基本没有内核切换的开销,可以不加锁的访问全局变量。
Goroutine之间是共享内存的。
一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这么大。
为什么线程切换开销比进程切换开销小:
进程的切换涉及虚拟内存空间的切换,而线程切换不涉及虚拟内存空间的切换。
多协程效率高于多线程的原因:
1.协程占用的资源远小于线程
2.协程的调度切换发生在用户态,避免了陷入内核级别的上下文切换而造成的性能损失
3.线程或进程的切换是由操作机制按照时间分片等机制来进行切换的,这种切换机制使得很多切换是没必要的,使得更多资源与时间耗费在了无谓的上下文切换上。
协程是半抢占式的协作调度,只有当协程阻塞时才会导致调度,阻塞的协程被切换出去,可运行的协程被切换进来。
new:分配内存,new 函数只接受一个参数,这个参数是一个类型,并且返回一个指向该类型内存地址的指针。同时 new 函数会把分配的内存置为零,也就是类型的零值。
make:make 也是用于内存分配的,但是和 new 不同,它只用于 chan、map 以及 slice 的内存创建,而且它返回的类型就是这三个类型本身,而不是他们的指针类型。(因为这三种类型就是引用类型,所以就没有必要返回他们的指针了)。
2023.3.31补充:
new也可以做slice、map、channel的内存分配,make相对于new的区别主要是make会对三种数据结构值做初始化(长度、容量等)。
只有当切片有值时,才能通过下标来操作切片。
Struct、Array、Slice,map的比较
如果struct结构体的所有字段都能够使用==
操作比较,那么结构体变量也能够使用==
比较。
但是,如果struct字段不能使用==
比较,那么结构体变量使用==
比较会导致编译错误。同样,array
只有在它的每个元素能够使用==
比较时(长度相同,类型相同),array变量才能够比较。
切片之间不能比较,只能和nil比较。
Go提供了一些用于比较不能直接使用==
比较的函数,reflect.DeepEqual()函数。
reflect.DeepEqual()函数对于nil值的slice与空元素的slice是不相等的,这点不同于bytes.Equal()函数。
读操作在更新操作进行前读取到数据,在更新操作删除缓存之后将旧数据更新到缓存。因为读操作的速度要快于写操作(更新),所以发生概率小。
为了减小这种情况造成的影响,可以为缓存设置过期时间。
A进程进行写操作,先成功淘汰缓存,但是由于网络或者其他原因,还未更新数据库或者正在更新。
B进程进行读操作,发现缓存中没有想要的数据,从数据库中读取数据,但是B线程只是从库中读取想要的数据,并不更新缓存。并不会导致缓存不一致。
A线程更新数据库后,通过订阅binlog来异步更新缓存,这样数据库和缓存一直都是 一致的。
1.串行化
保证对同一数据的读写严格按照先后顺序串行化进行,避免并发比较大的情况,多个线程对同意数据进行操作带来的缓存不一致的问题。
2.双删、设置缓存超时时间。
采用先淘汰再更新数据库的策略,如果并发比较大的情况下,在缓存失效前会存在较长时间的缓存不一致。可以采用双删策略。即更新前删除缓存,更新后也删除缓存。
如果对一致性要求很高,几乎不能用缓存
但如果不高,可以用最终一致:期间可能有短时间不一致,但一段时间后肯定能达到一致。
一致性Hash算法也是使用取模的方法,不过,一般的hash算法取模是对服务器的数量进行取模,而一致性的Hash算法是对2的32方取模。即,一致性Hash算法将整个Hash空间组织成一个虚拟的圆环,Hash函数的值空间为0 ~ 2^32 - 1(一个32位无符号整型)
将数据Key使用相同的函数Hash计算出哈希值,并确定此数据在环上的位置,从此位置沿环顺时针查找,遇到的服务器就是其应该定位到的服务器。
例如,现在有ObjectA,ObjectB,ObjectC三个数据对象,经过哈希计算后,在环空间上的位置如下:
现在,假设Node C宕机了,A、B不会受到影响,只有Object C对象被重新定位到Node A。
在一致性Hash算法中,如果一台服务器不可用,受影响的数据仅仅是此服务器到其环空间前一台服务器之间的数据(这里为Node C到Node B之间的数据),其他不会受到影响。
当系统增加了一台服务器Node X。
此时对象ObjectA、ObjectB没有受到影响,只有Object C重新定位到了新的节点X上。
bloom算法类似一个hash set,用来判断某个元素(key)是否在某个集合中。
和一般的hash set不同的是,这个算法无需存储key的值,对于每个key,只需要k个比特位,每个存储一个标志,用来判断key是否在集合中。
算法:
1. 首先需要k个hash函数,每个函数可以把key散列成为1个整数
2. 初始化时,需要一个长度为n比特的位图,每个比特位初始化为0
3. 某个key加入集合时,用k个hash函数计算出k个散列值,并把数组中对应的比特位置为1
4. 判断某个key是否在集合时,用k个hash函数计算出k个散列值,并查询位图中对应的比特位,如果所有的比特位都是1,认为在可能集合中。
过小的布隆过滤器很快所有的 bit 位均为 1,那么查询任何值都会返回“可能存在”,起不到过滤的目的了。布隆过滤器的长度会直接影响误报率,布隆过滤器越长其误报率越小。
哈希函数的个数也需要权衡,个数越多则布隆过滤器 bit 位置位 1 的速度越快,且布隆过滤器的效率越低;但是如果太少的话,那我们的误报率会变高。
hash函数的选择和数据特征不合适
一批数据经过一个hash函数运算,如果散列不均匀,数据都堆到一起了,误报率会变高。
哈希查找表一般会存在“碰撞”的问题,就是说不同的 key 被哈希到了同一个 bucket。
链地址法将一个 bucket 实现成一个链表,落在同一个 bucket 中的 key 都会插入这个链表。
开放地址法则是碰撞发生后,通过一定的规律,在数组的后面挑选“空位”,用来放置新的 key。
再哈希法:同时构造多个不同的哈希函数。
将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表。
每一个bucket里面的溢出指针 会指向另外一个 bucket,每一个bucket里面存放的是 8 个 key 和 8 个 value ,bucket 里面的溢出指针又指向另外一个bucket,用类似链表的方式将他们连接起来。
每个哈希表节点都有一个next指针, 多个哈希表节点可以用next指针构成一个单向链表, 被分配到同一个索引上的多个节点可以用这个单向链表连接起来, 这就解决了键冲突的问题。
当想要删除某个key时,需要将他的所有位都置为0,因为位数组上的某一位有可能被多个 key 所公用,所以删除会影响到其他元素。
布谷鸟过滤器源于布谷鸟Hash算法,布谷鸟Hash算法并不是使用位图实现的, 而是一维数组.。它所存储的是数据的指纹(fingerprint),是对数据的压缩(只用来代表数据)。
当有新的数据插入的时候,它会用两个hash函数计算出这个数据在数组中对应的两个位置,这个数据一定会被存在这两个位置之一。如果两个位置中有一个位置位空, 那么就可以将元素直接放进去. 但是如果这两个位置都满了, 它就会随机踢走一个, 然后自己霸占了这个位置。
被踢出的数据就去另一个hash算法找对应的位置,通过不断的踢出数据,最终所有数据都找到了自己的归宿。
直到所有位置都占满,这代表布谷Hash表走到了极限,需要将Hash算法优化(增加hash函数)或者扩容:①在一个位置上再设置四个位置,可以理解为小数组,这样的好处是数据连续,容易查询,但空间利用率下降 ② 给数组扩容。
由于布谷鸟过滤器在踢出数据时,要再次计算原数据在另一个Hash函数的值,因此设计Hash算法时将两个Hash函数变成了一个Hash函数,一个hash函数的备选位置是Hash(x),另一个hash函数的备选位置是Hash(x)⊕hash(fingerprint(x)),即第一个hash函数的位置与存储的指纹的Hash值做异或运算。这样可以直接用指纹的值 异或 原来位置的Hash值来计算出其另一个位置。
hash函数选择不好,数据集中到了一起,导致一直循环踢出。当踢出操作进行多次以后,会停止踢出,进行优化(上面的)。
直接删除指纹即可。
一个指纹占一个字节,8个bit位,有256种可能,如果数据特别多的情况下,可能出现一摸一样的指纹,如果他们用hash函数计算出的位置也一样,布谷鸟过滤器就会将他们视为一个数据,查询会出现误查询,删除的时候也可能误删数据。所以布谷鸟过滤器也存在误差。
进程间同步方式:
临界区、互斥区、信号量(pv操作)、事件
进程间通信方式:
管道pipe:管道是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
命名管道FIFO:有名管道也是半双工的通信方式,但是它允许无亲缘关系进程间的通信。
消息队列MessageQueue:
消息队列其实就是一个消息的链表,是一系列保存在内核中消息的列表。用户进程可以向消息队列添加消息,也可以向消息队列读取消息。
消息队列与管道相比,其优势是对每个消息指定特定的消息类型,接收的时候不需要按照队列次序进行接收,而是可以根据自定义条件接收特定的消息。
共享存储SharedMemory:共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信。
上述两种模型在操作系统中都常见,而且许多系统也实现了这两种模型。
消息队列对于交换较少数量的数据很有用,因为无需避免冲突。对于分布式系统,消息队列也比共享内存更易实现。
共享内存可以快于消息队列,这是因为消息队列的实现经常采用系统调用,因此需要消耗更多时间以便内核介入。而共享内存系统仅在建立共享内存区域时需要系统调用;一旦建立共享内存,所有访问都可作为常规内存访问,无需借助内核。
信号量Semaphore:信号量是一个计数器,可以用来控制多个进程对共享资源的访问。它常作为一种锁机制,防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。
套接字Socket:套接口也是一种进程间通信机制,与其他通信机制不同的是,它可用于不同机器间的进程通信。
信号 ( sinal ) : 信号是一种比较复杂的通信方式,用于通知接收进程某个事件已经发生。
孤儿进程:父进程结束,子进程就成为孤儿进程,会由1号进程(init进程——linux启动的第一个启动的进程)领养。
僵尸进程:进程结束但是没有完全释放内存(在内核中的task_struct没有释放),该进程就会成为僵尸进程。当僵尸进程的父进程结束后(变为孤儿僵尸进程)就会被init进程领养,最终被回收。
这里的I/O是网络I/O, 多路指的是多个TCP连接(如Socket),复用指的是复用一个线程。
采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),且 Redis 在内存中操作数据的速度非常快。
首先高性能的服务器不一定是多线程的,多线程(CPU上下文会切换会消耗cpu资源) 不一定比单线程效率高。
redis数据存放在内存上,对内存系统来说,多次读写都是在一个CPU上,上下文切换非常影响效率,所以用单线程以避免CPU上下文切换。并且在计算机中cpu的处理速度远大于内存的速度,cpu不会成为性能的瓶颈。(现在版本redis支持多线程)。
垃圾回收Garbage Collection,是编程语言中提供的内存管理功能。
在传统的系统级编程语言(主要指C/C++)中,程序员定义了一个变量,就是在内存中开辟了一段相应的空间来存值,当程序不再需要使用某个变量的时候,就需要销毁该对象并释放其所占用的内存资源,好重新利用这段空间。
在C/C++中,释放无用变量内存空间的事情需要由程序员自己来处理。就是说当程序员认为变量没用了,就手动地释放其占用的内存。
后来的语言释放无用变量内存空间的事情系统会自己做。
引用计数通过在对象上增加自己被引用的次数,被其他对象引用时加1,引用自己的对象被回收时减1,引用数为0的对象即为可以被回收的对象。这种算法在内存比较紧张和实时性比较高的系统中使用的比较广泛
优点:
1、方式简单,回收速度快。
缺点:
1、需要额外的空间存放计数。
2、无法处理循环引用(如a.b=b;b.a=a这种情况)。
3、频繁更新降低了引用计数的性能。
该方法分为两步,标记从根变量开始迭代得遍历所有被引用的对象,对能够通过应用遍历访问到的对象都进行标记为“被引用”;标记完成后进行清除操作,对没有标记过的内存进行回收(回收同时可能伴有碎片整理操作)。
这种方法解决了引用计数的不足,但是也有比较明显的问题:每次启动垃圾回收都会暂停当前所有的正常代码执行,回收使得系统响应能力大大降低!当然后续也出现了很多mark&sweep算法的变种(如三色标记法)优化了这个问题。
三色标记算法是对标记阶段的改进,原理如下:
可视化如下。
三色标记的一个明显好处是能够让用户程序和 mark 并发的进行。
复制收集的方式只需要对对象进行一次扫描。准备一个「新的空间」,从根开始,对对象进行扫描,如果存在对这个对象的引用,就把它复制到「新空间中」。一次扫描结束之后,所有存在于「新空间」的对象就是所有的非垃圾对象。
标记清除的方式节省内存但是两次扫描需要更多的时间,对于垃圾比例较小的情况占优势。
复制收集更快速但是需要额外开辟一块用来复制的内存,对垃圾比例较大的情况占优势。特别的,复制收集有「局部性」的优点。
在复制收集的过程中,会按照对象被引用的顺序将对象复制到新空间中。于是,关系较近的对象被放在距离较近的内存空间的可能性会提高,这叫做局部性。局部性高的情况下,内存缓存会更有效地运作,程序的性能会提高。
对于标记清除,有一种标记压缩算法的衍生算法:
对于压缩阶段,它的工作就是移动所有的可达对象到堆内存的同一个区域中,使他们紧凑的排列在一起,从而将所有非可达对象释放出来的空闲内存都集中在一起,通过这样的方式来达到减少内存碎片的目的。
这种收集方式用了程序的一种特性:大部分对象会从产生开始在很短的时间内变成垃圾,而存在的很长时间的对象往往都有较长的生命周期。
根据对象的存活周期不同将内存划分为新生代和老年代,存活周期短的为新生代,存活周期长的为老年代。这样就可以根据每块内存的特点采用最适当的收集算法。
新创建的对象存放在称为 新生代中(一般来说,新生代的大小会比 老年代小很多)。高频对新生成的对象进行回收,称为「小回收」,低频对所有对象回收,称为「大回收」。每一次「小回收」过后,就把存活下来的对象归为老年代,「小回收」的时候,遇到老年代直接跳过。大多数分代回收算法都采用的「复制收集」方法,因为小回收中垃圾的比例较大。
写屏障
这种方式存在一个问题:如果在某个新生代的对象中,存在「老生代」的对象对它的引用,它就不是垃圾了,那么怎么制止「小回收」对其回收呢?这里用到了一中叫做写屏障的方式。
程序对所有涉及修改对象内容的地方进行保护,被称为「写屏障」(Write Barrier)。写屏障不仅用于分代收集,也用于其他GC算法中。
在此算法的表现是,用一个记录集来记录从新生代到老生代的引用。如果有两个对象A和B,当对A的对象内容进行修改并加入B的引用时,如果①A是「老生代」②B是「新生代」。则将这个引用加入到记录集中。「小回收」的时候,因为记录集中有对B的引用,所以B不再是垃圾。
go语言垃圾回收总体采用的是经典的mark and sweep算法。
go runtime在一定条件下(内存超过阈值或定期如2min),暂停所有任务的执行,进行mark&sweep操作,操作完成后再启动所有任务的执行。当时解决这个问题比较常用的方法是尽快控制自动分配内存的内存数量以减少gc负荷,同时采用手动管理内存的方法处理需要大量及高频分配内存的场景。
go runtime分离了mark和sweep操作,先暂停所有任务执行并启动mark,mark完成后马上就重新启动被暂停的任务,让sweep任务和普通协程任务一样并行的和其他任务一起执行。如果运行在多核处理器上,go会试图将gc任务放到单独的核心上运行而尽量不影响业务代码的执行。
go 1.5正在实现的垃圾回收器是“非分代的、非移动的、并发的、三色的标记清除垃圾收集器”。引入了上文介绍的三色标记法,这种方法的mark操作是可以渐进执行的而不需每次都扫描整个内存空间,可以减少stop the world的时间。
这个版本的 GC 代码采用一种混合的 write barrier 方式来避免堆栈重新扫描。
混合屏障的优势在于它永久地使堆栈变黑(没有STW并且没有写入堆栈的障碍),这完全消除了堆栈重新扫描的需要,从而消除了对堆栈屏障的需求。重新扫描列表。特别是堆栈障碍在整个运行时引入了显着的复杂性,并且干扰了来自外部工具(如GDB和基于内核的分析器)的堆栈遍历。
此外,与Dijkstra风格的写屏障一样,混合屏障不需要读屏障,因此指针读取是常规的内存读取; 它确保了进步,因为物体单调地从白色到灰色再到黑色。
混合屏障的缺点很小。它可能会导致更多的浮动垃圾,因为它会在标记阶段的任何时刻保留从根(堆栈除外)可到达的所有内容。然而,在实践中,当前的Dijkstra障碍可能几乎保留不变。混合屏障还禁止某些优化:特别是,如果Go编译器可以静态地显示指针是nil,则Go编译器当前省略写屏障,但是在这种情况下混合屏障需要写屏障。这可能会略微增加二进制大小。
小结:
通过go team多年对gc的不断改进和优化,GC的卡顿问题在1.8 版本基本上可以做到 1 毫秒以下的 GC 级别。 实际上,gc低延迟是有代价的,其中最大的是吞吐量的下降。由于需要实现并行处理,线程间同步和多余的数据生成复制都会占用实际逻辑业务代码运行的时间。GHC的全局停止GC对于实现高吞吐量来说是十分合适的,而Go则更擅长与低延迟。
并行GC的第二个代价是不可预测的堆空间扩大。程序在GC的运行期间仍能不断分配任意大小的堆空间,因此我们需要在到达最大的堆空间之前实行一次GC,但是过早实行GC会造成不必要的GC扫描,这也是需要衡量利弊的。因此在使用Go时,需要自行保证程序有足够的内存空间。
垃圾收集是一个难题,没有所谓十全十美的方案,通常是为了适应应用场景做出的一种取舍。
对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 O(n)。
跳跃表是一种有序的数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的(类似有序数组二分查找的效果)。
可以通过在链表上建索引的方式。每两个结点提取一个结点到上一级,我们把抽出来的那一级叫作索引。
假设要查找节点8,先在索引层遍历,当遍历到索引层中值为 7 的结点时,发现下一个节点是9,那么要查找的节点8肯定就在这两个节点之间。
下降到链表层继续遍历就找到了8这个节点。
原先在单链表中找到8这个节点要遍历8个节点,而现在有了一级索引后只需要遍历五个节点。
从这个例子里,可以看出,加来一层索引之后,查找一个结点需要遍的结点个数减少了,也就是说查找效率提高了,同理可以增加多层索引,注意索引层节点数要大于2。
像这种链表加多级索引的结构,就是跳跃表!
当新数据不断插入到连接,索引层的索引节点会逐渐不够用,这时候就需要从下层“提拔”一批索引,这一步骤不可预测,所以采用“抛硬币”的方法让大体趋于均匀:即新数据插入到链表以后,会有1/2的概率“提拔”到上一层索引,接着又有1/2的概率再上一层...以此类推。
当有元素要删除时,会自上而下,查找第一次出现节点的索引,并逐层找到每一层对应的节点。
删除每一层查找到的节点,如果该层只剩下1个节点,删除整个一层(原链表除外)。
跳跃表维持平衡的成本比较低,完全靠随机,而二叉树则需要Rebalance重新调整结构平衡。
LRU(Least recently used,最近最少使用)算法根据数据的历史访问记录来进行淘汰数据,其核心思想是“如果数据最近被访问过,那么将来被访问的几率也更高”。
过程如下:
RPC跟HTTP不是对立面,RPC中可以使用HTTP作为通讯协议(也可以使用TCP来作为通讯协议)
RPC是一种设计、实现框架,通讯协议只是其中一部分。
RPC的本质是提供了一种轻量无感知的通信的方式,在分布式机器上调用其他方法与本地调用无异(远程调用的过程是透明的,你并不知道这个调用的方法是部署在哪里,通过PRC能够解耦服务)。同时http协议依赖于服务器ip地址和端口号,耦合性很高。
协议
1.RPC:可以基于TCP实现,也可以基于HTTP实现
2.HTTP:基于HTTP协议
报文长度
1.RPC:自定义具体实现可以减少很多无用的报文内容,使得报文体积更小
2.HTTP:如果是HTTP1.1,它的报文中有很多内容都是无用的。如果是HTTP2.0以后和RPC相差不大,缺少的是RPC的一些服务治理功能。所以效率差主要还是基于HTTP1.1来说。
连接方式
1.RPC:建立长连接,减少网络消耗。
2.HTTP:每次连接都是三次握手
序列化传输
1.RPC可以基于很多种序列化方式。如:protobuf和thrift
2.HTTP主要通过JSON,序列化和反序列化,这样的效率较低
调用函数
http需要依赖一个文档才能知道函数需要什么参数,会返回什么,而文档和http协议是分开维护的,如果文档更新不及时,也会出错。
rpc(grpc)有自己的描述文件,可以直观的了解函数的情况。
RPC是根据语言的API来定义的,而不是基于网络的应用来定义的,调用更方便,协议私密更安全、内容更小效率更高。(根据语言API定义?不懂)
小结:
RPC一般支持微服务框架丰富的治理功能,更适合企业内部的接口调用。而HTTP更适合多平台之间的相互调用
grpc采用Protobuf生成高效的二进制编码,Protobuf的压缩率是非常高的;这使得它比 JSON/HTTP 快很多。
grpc采用http2协议,http2相较于http有更多的优势:
1. 多路复用
2. 头部压缩
3. 二进制分帧
4. 服务器主动推送资源
HTTP 与 RPC 的关系就好比普通话与方言的关系。
要进行跨企业服务调用时,往往都是通过 HTTP API,也就是普通话,虽然效率不高,但是通用,没有太多沟通的学习成本。但是在企业内部还是 RPC 更加高效,同一个企业公用一套方言进行高效率的交流,要比通用的 HTTP 协议来交流更加节省资源。
整个中国有非常多的方言,正如有很多的企业内部服务各有自己的一套交互协议一样。虽然国家一直在提倡使用普通话交流,但是这么多年过去了,你回一趟家乡探个亲什么的就会发现身边的人还是流行说方言。
RPC主要用于公司内部的服务调用,性能消耗低,传输效率高,服务治理方便。HTTP主要用于对外的异构环境,浏览器接口调用,APP接口调用,第三方接口调用等。
HTTP:HTTP是超文本传输协议,以明文方式发送内容,不提供任何方式的数据加密,不适合传输一些敏感信息。
HTTPS: HTTPS是安全套接字层超文本传输协议,为了数据传输的安全,HTTPS在HTTP的基础上加入了SSL协议,SSL依靠证书来验证服务器的身份,并为浏览器和服务器之间的通信加密。
http和https使用的是完全不同的连接方式,用的端口也不一样,前者是80,后者是443。
http的连接很简单,是无状态的;HTTPS协议是由SSL+HTTP协议构建的可进行加密传输、身份认证的网络协议,比http协议安全。
HTTPS对比HTTP主要实现了身份认证数据加密以及信息完整性验证。
利用非对称加密实现身份认证和密钥协商,对称加密算法采用协商的密钥对数据加密,基于散列函数验证信息的完整性。
1.验证服务端身份(不容易被伪装)
通过第三方权威机构颁发的CA证书(第三方机构用自己的私钥对服务器的公钥进行数字签名)
客户端在收到服务器的证书后,用第三方机构的公钥对数字签名验证,验证通过则认为服务器是安全的。
(
那么第三方机构的公钥如何传输到客户机呢?
一般操作系统和浏览器在发布的时候就会将权威机构的公钥预置
所以要谨慎安装第三方打包的操作系统和浏览器
因为它们可能预置了第三方的根证书
)
数字证书也可以自己制作,例如12306
但因为没有第三方验证,浏览器会报不安全
当然也就失去了验证服务端身份的作用(少了一个公证人的公证,不能保证绝对安全)
2.对传输的信息加密(不易被监听)
用来传输大量信息的对称加密密钥是用非对称加密进行加密的
固在传输过程中避免被人解惑
(监听到的是密文)
3.对传输的内容进行数字签名(防篡改)
对要传输的内容用摘要算法得出内容的数字指纹
再将数字指纹和内容都加密后传给对方
对方解密后用同样的方法对内容进行签名
如果签名后的数字指纹和传过来的一样
就证明没有被篡改
HTTPS采用不对称加密JK+对称加密B
服务器将不对称密钥K交给浏览器(客户端),客户端给服务器一个对称密钥B,之后就通过这个B来进行加密通信。
证书由权威机构用私钥加密,客户端用权威机构发布的公钥来解密,获得服务端发来的公钥,再用服务端公钥加密一个对称密钥,服务端收到这个对称密钥之后,就用这个对称密钥来与客户端通信。
采用https协议的服务器必须要有一套数字证书
可以自己制作,也可以向组织申请
(区别就是自己制作的证书需要通过客户端验证通过,才能继续访问)
这套证书其实就是一对公钥和私钥
用户在浏览器输入一个https网址,然后连接到服务器的443端口
请求内容包括:
1.客户端支持的协议版本(比如TLS1.0)
2.客户端支持的加密算法
3.客户端支持的加密方法
服务端传送公钥给客户端
其中还包含许多信息,
如证书颁发机构、过期时间等
1.首先验证公钥是否有效
(比如颁发机构、过期时间等)
如发现异常,则会弹出证书存在问题的警告框
2.验证对方是否为证书的合法持有者
(也就是验证对方是否持有证书验证签名)
如果验证通过,或未通过但用户同意,则生成一个对称加密的密钥
(即之后用于加密大量通讯数据的密钥)
然后用证书(访问端的公钥)对改随机值进行加密
将用证书加密后的密钥传给服务器
(那么以后客户端和服务端的通信就可以通过这个随机值来加解密了)
服务端用私钥解密后,得到客户端传过来的对称加密密钥
到此为止服务器和客户端已经用非对称加密的方法协商好了一个
对称加密的密钥,那么后期的通讯就可以用该非对称加密的密钥
进行加密解密
因为非对称加密的复杂度高,影响性能,
所以后期要换做对称加密
HTTP协议是HyperText Transfer Protocol(超文本传输协议)的缩写,它是互联网上应用最为广泛的一种网络协议。所有的WWW文件都必须遵守这个标准。伴随着计算机网络和浏览器的诞生,HTTP1.0也随之而来,处于计算机网络中的应用层.
HTTP/1.0引入了请求头和响应头。
同时也引入了状态码,为了减轻服务器的压力,提供了Cache机制。服务器需要统计客户端的基础信息,加入了用户代理字段。
请求头中增加connection: keep-alive支持,针对同一个tcp进行复用,减少tcp握手带来的时间。
缓存头中提供了更多的属性来支持不同的缓存策略。在HTTP1.0中主要使用header里的If-Modified-Since,Expires来做为缓存判断的标准,HTTP1.1则引入了更多的缓存控制策略例如Entity tag,If-Unmodified-Since, If-Match, If-None-Match等更多可供选择的缓存头来控制缓存策略。
HTTP/1.0中每个域名都只绑定唯一的IP地址,因此一个服务器只能支持一个域名。
但是随着虚拟主机技术的发展,一台物理主机上绑定多个虚拟主机的需求大大提升,每个虚拟主机都有自己单独的域名,这些单独的域名都公用同一个IP地址。
因此,请求头中也增加了Host字段,表示当前的域名地址,服务器可根据不同的Host值做不同的处理,支持同一个IP下的不同服务器提供服务。
在HTTP1.1中新增了24个错误状态响应码,如409(Conflict)表示请求的资源与资源的当前状态发生冲突;410(Gone)表示服务器上的某个资源被永久性的删除。
HTTP1.0中,存在一些浪费带宽的现象,例如客户端只是需要某个对象的一部分,而服务器却将整个对象送过来了,并且不支持断点续传功能。
HTTP1.1则在请求头引入了range头域,它允许只请求资源的某个部分,即返回码是206(Partial Content),这样就方便了开发者自由的选择以便于充分利用带宽和连接。
SPDY的方案优化了HTTP1.X的请求延迟,解决了HTTP1.X的安全性,具体如下:
降低延迟,针对HTTP高延迟的问题,SPDY优雅的采取了多路复用。多路复用通过多个请求stream共享一个tcp连接的方式,解决了HOL blocking的问题,降低了延迟同时提高了带宽的利用率。
请求优先级。多路复用带来一个新的问题是,在连接共享的基础之上有可能会导致关键请求被阻塞。SPDY允许给每个request设置优先级,这样重要的请求就会优先得到响应。比如浏览器加载首页,首页的html内容应该优先展示,之后才是各种静态资源文件,脚本文件等加载,这样可以保证用户能第一时间看到网页内容。
header压缩。前面提到HTTP1.x的header很多时候都是重复多余的。选择合适的压缩算法可以减小包的大小和数量。
基于HTTPS的加密协议传输,大大提高了传输数据的可靠性。
服务端推送,采用了SPDY的网页,例如我的网页有一个sytle.css的请求,在客户端收到sytle.css数据的同时,服务端会将sytle.js的文件推送给客户端,当客户端再次尝试获取sytle.js时就可以直接从缓存中获取到,不用再发请求了。SPDY构成图:
HTTP2是基于SPDY设计的,是SPDY的升级版。
HTTP2.0和HTTP1.X相比的新特性
新的二进制格式,HTTP1.x的解析是基于文本。基于文本协议的格式解析存在天然缺陷,文本的表现形式有多样性,要做到健壮性考虑的场景必然很多,二进制则不同,只认0和1的组合。基于这种考虑HTTP2.0的协议解析决定采用二进制格式,实现方便且健壮。
多路复用,同域名下多个通信可以在单个连接上完成;单个连接可以承载任意数量的双向数据流;数据流以消息的形式发送,而消息又由一个或多个帧组成,多个帧之间可以乱序发送,最后根据帧首部的流标识可以重新组装。某个请求任务耗时严重,不会影响到其它连接的正常执行。
header压缩,如上文中所言,对前面提到过HTTP1.x的header带有大量信息,而且每次都要重复发送,HTTP2.0使用encoder来减少需要传输的header大小,通讯双方各自cache一份header fields表,既避免了重复header的传输,又减小了需要传输的大小。
服务端推送 ,同SPDY一样,HTTP2.0也具有server push功能。
HTTP2.0和SPDY的区别:
HTTP2.0 支持明文 HTTP 传输,而 SPDY 强制使用 HTTPS。(HTTP不加密)
HTTP2.0 消息头的压缩算法采用 HPACK ,而非 SPDY 采用的 DEFLATE 。
由于HTTP1 HTTP1.x HTTP2都是基于TCP开发的,其中的TCP握手问题就无法避免,为了解决这个问题,Google 就另起炉灶搞了一个基于 UDP 协议的 QUIC 协议,并且使用在了 HTTP/3 上。其特点主要为:
根据扩容的原因不同,map扩容可以分为两类型:等量扩容或双倍扩容。
双倍扩容:触发load factor的最大值,负载因子已达到当前界限。
等量扩容(不改变大小):溢出桶 overflow buckets 过多。扩容后的buckets 的数量和原来是一样的,说明可能是空kv占据的坑太多了,通过map扩容做内存整理。
负载因子 load factor,用途是评估哈希表当前的时间复杂度,其与哈希表当前包含的键值对数、桶数量等相关。
如果负载因子越大,则说明空间使用率越高,但产生哈希冲突的可能性更高。
而负载因子越小,说明空间使用率低,产生哈希冲突的可能性更低。
溢出桶 overflow buckets 的判定与 buckets 总数和 overflow buckets 总数相关联。
新申请的扩容空间都是预分配,等真正使用的时候才会初始化。
扩容完毕后(预分配),不会马上就进行迁移。
而是采取增量扩容的方式,当有访问到具体 bukcet 时,才会逐渐的进行迁移(将 oldbucket 迁移到 bucket)。
既然迁移是逐步进行的。那如果在途中又要扩容了,怎么办?
结合上下文可得若正在进行扩容,就会不断地进行迁移。待迁移完毕后才会开始进行下一次的扩容动作。
程序写在用户态,用户态是不知道io状态的,只能通过对内核态的函数调用来获取这些信息。
对于一次IO访问,数据会被先拷贝到操作系统内核缓冲区
然后再从内核缓冲区拷贝到应用程序的地址空间
所以当一个read操作发生时,会经历两个阶段
1.等待数据准备(操作系统先将IO的数据缓存在文件系统的缓存页(page cache)中)
2.将数据从内核缓冲区拷贝到应用程序的地址空间。
正因为这两个阶段,Linux系统产生了5种网络模式的方案:
用户进程调用recvfrom时,kernel开始准备数据,用户进程则阻塞等待
当等到kernel准备好数据后,再将数据从kernel拷贝到用户空间内存
然后kernel返回结果,用户进程才能解除block的状态重写运行起来
当用户进程进行read操作时,若kernel中数据还未准备好,
那么并不会block用户进程,而是立即返回一个error
用户判断是error,就知道没准备好,直到准备好,再调用read时
就可以把kernel中数据拷贝到用户内存空间啦
当用户调用了select后,整个进程会被block,select会将要监视的文件描述符集传递给内核(用户态到内核态)
kernel会监视所有kernel负责的socket,并进行轮询。
当任何一个或多个socket中的数据准备好了,select就会返回整个文件描述符集给用户态
用户进程再轮询文件描述符集,找到就绪的文件,调用read操作,将数据从kernel拷贝到用户进程
优点:可以同时处理多个connection
缺点:需要两个系统调用,select和recvfrom
所以如果连接量不大的话,多线程阻塞模型更高效
当用户调用了read后会立即返回,通知内核监视指定的文件,当数据准备就绪时,内核会给用户进程发送一个signal,告诉它可以开始拷贝操作了。
用户发起read操作后,会立即返回
kernel等待数据准备完成,将数据拷贝到用户内存空间
然后kernel会给用户进程发送一个signal,告诉它read操作完成。
同步与异步是针对应用程序与内核的交互而言的,关注的是程序之间的协作关系。
阻塞与非阻塞更关注的是单个进程的执行状态。
同步是指:当程序1调用程序2时,程序1停下不动,直到程序2完成回到程序1来,程序1才继续执行下去。
异步是指:当程序1调用程序2时,程序1径自继续自己的下一个动作,不受程序2的影响。
同步是指:发送方发出数据后,等接收方发回响应以后才发下一个数据包的通讯方式。
异步是指:发送方发出数据后,不等接收方发回响应,接着发送下个数据包的通讯方式。
阻塞调用是指调用结果返回之前,当前线程会被挂起。函数只有在得到结果之后才会返回。
有人也许会把阻塞调用和同步调用等同起来,实际上他是不同的。对于同步调用来说,很多时候当前线程还是激活的,只是从逻辑上当前函数没有返回而已。(同步和阻塞是两个层面的东西,不做比较)
非阻塞和阻塞的概念相对应,指在不能立刻得到结果之前,该函数不会阻塞当前线程,而会立刻返回。
真正的异步IO需要CPU的深度参与,只有用户线程在操作IO的时候根本不去考虑IO的执行,全部交给CPU完成,自己的执行完全不受其他程序的影响,只等待一个完成信号的时候,才是真正的异步IO。
所以拉一个子线程去轮询或使用select、poll、epool都不是异步。
select、poll、epoll都是IO多路复用机制
IO多路复用就是通过一种机制,可以让一个进程监视多个文件描述符(一个进程通过调用内核来实现监视,实质上是内核在监视文件io情况),一旦某个描述符就绪,就能通知程序进行相应的读写操作。
但它们本质上都是同步IO,因为它们都需要在读写事件就绪后自己负责读写(从内核空间拷贝到用户空间)也就是说这个读写过程是阻塞的。
而异步IO则无需自己负责进行读写,异步IO的实现会负责把数据从内核空间拷贝到用户空间后,才通知用户进程进行处理。
select监视的文件==>时间复杂度O(n)描述符分为3类,用三个位图来表示fdset:writefds(可写)、readfds(可读)、exceptfds(异常)
调用流程:
调用select函数会阻塞,向内核拷贝整个fd(文件描述符集)(每次调用select都会完整的拷贝一份fd),通知内核监视fd,内核会轮询这些fd,直到有描述符就绪,(有数据可读、可写或有except),或者等待超时(若立即返回则设为null)。
当select返回后,内核会把整个fd拷贝给用户态程序,用户态程序从select那里仅仅知道有IO事件发生了,但并不知道是哪几个流,只能轮询所有流,对它们进行操作。
优点:
select目前几乎支持所有平台(良好的跨平台支持)。
缺点:
1.单进程能够监视的文件描述符数量存在最大限制
linux一般为1024(可修改宏定义或重新编译内核提升但会造成效率降低)
2.当某个连接有数据后,内核会通知用户有数据了,但不告诉是哪个,用户只能通过轮询一个个检查。
3.只支持水平触发
4. 调用slect需要把fd集合从用户态拷贝到内核态,来告知内核需要监视哪些文件描述符,同时每次调用select都需要在内核遍历传递进来的所有fd(fd很多时,开销会很大),poll同样存在这个问题。
poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态, 但是它没有最大连接数的限制,原因是它是基于链表来存储的。
和select一样,poll返回后,需要轮询pollfd来获取就绪的文件描述符
因为同时连接的大量客户端在同一时刻只有很少处于就绪状态,因此随着监视的描述符数量的增长,其效率也会线性下降。
poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
epoll是在linux2.6内核中提出的,是linux内核为处理大量文件描述符而做了改进的poll,能显著提高程序在大量并发连接中只有少量活跃的情况下的cpu的利用率。
epoll可以理解为event poll(事件池),不同于select和poll,epoll会把哪个流发生了怎样的I/O事件通知我们。所以我们说epoll实际上是事件驱动(每个事件关联上fd)的,此时我们对这些流的操作都是有意义的。(复杂度降低到了O(1))。
epoll对比select和poll的优点:
1、没有最大并发连接的限制,能打开的FD的上限远大于1024(1G的内存上能监听约10万个端口);
2、效率提升,不是轮询的方式,不会随着FD数目的增加效率下降。只有活跃可用的FD才会调用callback函数;
即Epoll最大的优点就在于它只管你“活跃”的连接,而跟连接总数无关,因此在实际的网络环境中,Epoll的效率就会远远高于select和poll。
3、 epoll通过内核和用户空间共享一块内存来实现的消息的传递,用户空间和内核空间的消息传递,只需要拷贝一次fd。而不是像select和poll每次调用都需要传递全部fd。
4. 同时支持水平触发和边缘触发。
epoll的原理
将用户要监视的文件描述符的事件存放到内核的一个事件表中,这个表共享内存,这样在用户空间和内核空间的copy只需一次。
把原先的select/poll调用分成了3个函数:
1)调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源,epoll使用一个文件描述符管理多个文件描述符)。
当某一进程调用epoll_create方法时,Linux内核会创建一个eventpoll结构体,这个结构体其中的两个成员:1. 红黑树的根节点,这颗树中存储着所有添加到epoll中的需要监控的事件 2. 双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件。
每一个epoll对象都有一个独立的eventpoll结构体,用于存放通过epoll_ctl方法向epoll对象中添加进来的事件。
2)调用epoll_ctl向epoll对象中添加时间表中的事件。
epoll_ctl()系统调用。通过此调用向epoll对象中添加、删除、修改要监听的事件,返回0标识成功,返回-1表示失败。对于每一个事件,都会建立一个epitem结构体。
这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。
而所有添加到epoll中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫ep_poll_callback,它会将发生的事件添加到rdlist双链表中。
在epoll中,对于每一个事件,都会建立一个epitem结构体:
struct epitem{
struct rb_node rbn;//红黑树节点
struct list_head rdllink;//双向链表节点
struct epoll_filefd ffd; //事件句柄信息
struct eventpoll *ep; //指向其所属的eventpoll对象
struct epoll_event event; //期待发生的事件类型
}
3)调用epoll_wait检查是否有事件就绪(即双链表中是否有元素)
当调用epoll_wait检查是否有事件就绪时,不需要像select或者poll那样遍历整个fd,只需要检查eventpoll对象中的rdlist双链表中是否有epitem元素即可。如果rdlist不为空,则把就绪的事件复制到用户态,同时将事件数量返回给用户。
1.epoll会在被内核初始化的时候(操作系统启动时),开辟出自己的内核高速cache区(该catch区用来存放我们想监控的socket所组成的红黑树eventpoll)。
支持快速的查找、插入、删除
2.调用epoll_create时
(1)在内核cache里建立红黑树用于存储以后epoll_ctl传来的socket
(2)建立双向链表epitem用于存储准备就绪的事件
3.当我们执行epoll_ctl时,
(1)如果增加socket,先检测红黑树中是否存在,存在则立即返回,不存在则把要监听的socket放到epoll文件系统里file对象对应的红黑树eventpoll上。
(2)给内核中断处理程序注册一个回调函数(告诉内核,若该句柄的中断到了,就把其放到epitem里),可见epoll的基础就是回调。
4.当我们调用epoll_wait(告诉)时,不需要传递socket句柄给内核
因为内核已经存有要监控的句柄,此时仅仅观察epitem链表里有没有数据即可,有数据就返回,没有数据就sleep。
1、支持一个进程所能打开的最大连接数
select:单个进程所能打开的最大连接数有FD_SETSIZE宏定义,其大小是32个整数的大小(在32位的机器上,大小就是3232,同理64位机器上FD_SETSIZE为3264),当然我们可以对进行修改,然后重新编译内核,但是性能可能会受到影响,这需要进一步的测试。
poll:poll本质上和select没有区别,但是它没有最大连接数的限制,原因是它是基于链表来存储的。
epoll:虽然连接数有上限,但是很大,1G内存的机器上可以打开10万左右的连接,2G内存的机器可以打开20万左右的连接。
2、FD剧增后带来的IO效率问题
select:因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题”。
poll:同上
epoll:因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降的性能问题,但是所有socket都很活跃的情况下,可能会有性能问题。
3、 消息传递方式
select:内核需要将消息传递到用户空间,都需要内核拷贝动作
poll:同上
epoll:epoll通过内核和用户空间共享一块内存来实现的。
设想一下如下场景:有100万个客户端同时与一个服务器进程保持着TCP连接。
而每一时刻,通常只有几百上千个TCP连接是活跃的(事实上大部分场景都是这种情况)。
如何实现这样的高并发?
在select/poll时代,服务器进程每次都把这100万个连接告诉操作系统(从用户态复制句柄数据结构到内核态),让操作系统内核去轮询这些套接字上是否有事件发生。
轮询完后,再将整个句柄数据复制到用户态,让服务器应用程序轮询处理已发生的网络事件,这一过程资源消耗较大,因此,select/poll一般只能处理几千的并发连接。
epoll的设计和实现与select完全不同。
epoll通过在Linux内核中申请一个简易的文件系统。把原先的select/poll调用分成了3个部分:
1)调用epoll_create()建立一个epoll对象(在epoll文件系统中为这个句柄对象分配资源)
2)调用epoll_ctl向epoll对象中添加这100万个连接的套接字
3)调用epoll_wait收集发生的事件的连接
如此一来,要实现上面说是的场景,只需要在进程启动时建立一个epoll对象,然后在需要的时候向这个epoll对象中添加或者删除连接。同时,epoll_wait的效率也非常高,因为调用epoll_wait时,并没有一股脑的向操作系统复制这100万个连接的句柄数据,内核也不需要去遍历全部的连接。
将就绪的文件描述符告诉进程后
如果进程没有对其进行IO操作
那么下次调用epoll时将再次报告这些文件描述符
只告诉哪些文件描述符刚刚变为就绪状态
它只说一遍,如果我们没有采取行动,它将不会再次告知
理论上边缘触发性能更高,但代码实现相当复杂
一个channel里的数据只能被一个goroutine读取到,底层的原理如下:
type hchan struct {
qcount uint // total data in the queue 当前队列里还剩余元素个数
dataqsiz uint // size of the circular queue 环形队列长度,即缓冲区的大小,即make(chan T,N) 中的N
buf unsafe.Pointer // points to an array of dataqsiz elements 环形队列指针
elemsize uint16 //每个元素的大小
closed uint32 //标识当前通道是否处于关闭状态,创建通道后,该字段设置0,即打开通道;通道调用close将其设置为1,通道关闭
elemtype *_type // element type 元素类型,用于数据传递过程中的赋值
sendx uint // send index 环形缓冲区的状态字段,它只是缓冲区的当前索引-支持数组,它可以从中发送数据
recvx uint // receive index 环形缓冲区的状态字段,它只是缓冲区当前索引-支持数组,它可以从中接受数据
recvq waitq // list of recv waiters 等待读消息的goroutine队列
sendq waitq // list of send waiters 等待写消息的goroutine队列
// lock protects all fields in hchan, as well as several
// fields in sudogs blocked on this channel.
//
// Do not change another G's status while holding this lock
// (in particular, do not ready a G), as this can deadlock
// with stack shrinking.
lock mutex //互斥锁,为每个读写操作锁定通道,因为发送和接受必须是互斥操作
}
// sudog 代表goroutine
type waitq struct {
first *sudog
last *sudog
}
当有一个goroutine要往channel写入数据时:
当有一个goroutine要从channel读取数据时:
ps -ef :列出所有进程,并显示环境变量,而且显示全格式。
ps -a : 只列出所有进程,并不显示环境变量。
kill pid
-9 表示强迫进程立即停止
netstat
grep是用来在文件内部查找文字内容的,,而find是用来查找文件的。
find :find 某个范围下 -name 模糊匹配
-type f :类型是f
eg:find .(当前目录及其子目录下) -name “*.c”
grep:grep -参数 要匹配的字符串 查找的范围(模糊查询:前缀是某某,后缀是某某等等)
将文件从第一行开始,根据输出窗口的大小,适当的输出文件内容。当一页无法全部输出时,可以可以翻页,less对比more,支持向前翻页。
tail -f
显示当前系统正在执行的进程的相关信息,包括进程 ID、内存占用率、CPU 占用率等
top 命令来查看 CPU 使用状况,包括用户空间和内核空间
load average后面的三个数分别是1分钟、5分钟、15分钟的负载情况。
在Go一共可以分为两种抢锁的模式,一种是正常模式,另外一种是饥饿模式。
正常模式(非公平锁)
在刚开始的时候,是处于正常模式(Barging),也就是,当一个G1持有着一个锁的时候,G2会自旋的去尝试获取这个锁;
当自旋超过4次还没有能获取到锁的时候,这个G2就会被加入到获取锁的等待队列里面,并阻塞等待唤醒。
当G1释放锁时,等待队列里的G2会被唤醒,与其他协程竞争锁,这样,处于自旋状态下的协程更容易获得锁;
饥饿模式是 1.9 版本中引入的优化,目的是保证互斥锁的公平性,防止协程饿死。默认情况下,Mutex 的模式为正常模式。
饥饿模式(公平锁)
为了解决了等待 goroutine 队列的长尾问题,在饥饿模式下,直接由 unlock 把锁交给等待队列中排在第一位的 goroutine (队 头),
同时,饥饿模式下,新进来的 goroutine 不会参与抢锁也不会进入自旋状 态,会直接进入等待队列的尾部。这样很好的解决了老的 goroutine一直抢不 到锁的场景。
饥饿模式的触发条件:当一个 goroutine 等待锁时间超过 1 毫秒时,或者当前 队列只剩下一个 goroutine 的时候,Mutex 切换到饥饿模式。