Go语言的特点和优势
- 语法简单
- 支持轻量级线程(gorouting)和通信(channel),高并发
- 内置垃圾回收
Go和Java对比
Java使⽤⼴泛,但是Go⽐Java更适合⾼并发和轻量级的应⽤
Java通过线程和锁来处理并发, Goroutines和channels是Go语⾔的并发特性的核⼼
Java是⼀⻔功能丰富、⾯向对象的语⾔,⽀持⾯向对象编程、泛型等⾼级特性。 Go语⾔的设计注重简洁和清晰,具有简单的语法和类型系统。它摒弃了⼀些复杂的特性,强调代码的可读性。
Go语⾔具有垃圾回收机制,开发者⽆需⼿动管理内存。Java同样拥有垃圾回收机制,这减轻了开发者的负担,但在⼀些情况下可能引⼊⼀些不可控的暂停。
Go适⽤于构建⾼性能、⾼并发的后端服务、⽹络应⽤、云服务以及分布式系统。Java⼴泛应⽤于⼤型企业应⽤、Android应⽤、⼤规模分布式系统和企业级应⽤。
Go string 和 []byte 的区别
频繁修改字符串、处理二进制数据使用[]byte
字符串内容基本不变,主要处理文本数据使用string
不可变性
string 是不可变数据类型,创建后不能被修改。
修改 string 的操作都会产⽣⼀个新的 string,⽽原始的 string 保持不变。相⽐之下, []byte 是可变的切⽚,可以通过索引直接修改切⽚中的元素。
类型转换
使⽤ []byte(s) 可以将 string 转换为 []byte
使⽤string(b) 可以将 []byte 转换为 string
类型转换会创建新的底层数组,转换后的修改不回影响原数据
内存分配
string 底层数据只读。 string 的内存分配和释放由Go运⾏时管理。
[]byte 是⼀个可变的切⽚,底层数据是可以修改的。 []byte 的内存管理由程序员负责。
Unicode字符
string 中每个元素是⼀个 Unicode 字符
[]byte 中的每个元素是⼀个字节
string 可以包含任意字符,⽽ []byte 主要⽤于处理字节数据。
make和new的区别
new只⽤于分配内存,返回⼀个指向地址的指针。它为指定的类型T分配⼀⽚内存,初始化为零值且返回类型*T的内存地址,它相当于&T{}
make只可⽤于slice,map,channel的初始化,返回的是引⽤(类型本身)。
对于slice,map,channel而言,make()和new()的显著区别在于make能初始化值,而new并不会初始化值
数组和切片的区别
数组
固定⻓度,声明数组时指定⻓度,且不能更改,值拷贝。
数组的元素在内存中是顺序存储的,分配在⼀块连续的内存区域
切⽚
切⽚的⻓度可以动态调整,⽽且可以不指定⻓度,引用传递。
切⽚本身不存储元素,⽽是引⽤⼀个底层数组。切⽚的底层数组会在需要时进⾏动态扩展。
切片扩容机制
实际上Go 可能会分配比计算得出的容量更大的内存,以减少后续的扩容操作,具体由 runtime.growslice 机制决定。
如果扩容后的容量仍然能够容纳新元素,系统会尽量在原地进⾏扩容,否则会分配⼀个新的数组,将原有元素复制到新数组中。
切⽚内部实现的数据结构通过指针引⽤底层数组,设定相关属性将数据读写操作限定在指定的区域内。切⽚本身是⼀ 个只读对象,其⼯作机制类似数组指针的⼀种封装。 主要通过⼀个结构体来表示,该结构体包含了以下三个字段:
1
2
3
4
5 type slice struct {
array unsafe.Pointer // 指向底层数组的指针
len int // 切⽚的当前⻓度
cap int // 切⽚的容量
}
参数传递
- 基本数据类型和结构体,传递值的副本
- 切⽚、映射、通道等引⽤类型,传递指针或变量本身的地址
Map
Go语⾔map是有序的还是⽆序的, 为什么
当 map的元素数量达到阈值时,Go语⾔会动态调整 map的⼤⼩。
这是因为 map的实现采⽤了散列表(hash table)的数据结构。散列表通过哈希函数将键映射到存储桶(bucket)散列表中的存储桶是⽆序的,它们并不保证元素按照特定顺序存储。
Map扩容机制
计算新的存储桶数量:当 map的元素数量达到负载因⼦(load factor)的上限时,会触发扩容。新的存储桶数量通常会是当前存储桶数量的两倍。
分配新的存储桶和散列数组:创建新的存储桶和散列数组,⼤⼩为新的存储桶数量。这个过程会涉及到内存分配。
重新散列元素:遍历当前 map的每个存储桶,将其中的元素重新散列到新的存储桶中。这⼀步是为了保持元素在新的存储桶中的顺序。
切换到新的存储桶和散列数组:将 map的内部数据结构指向新的存储桶和散列数组。这个过程是原⼦的,以确保在切换期间不会影响并发访问。
释放旧的存储桶和散列数组:释放旧的存储桶和散列数组的内存空间。这个过程是为了避免内存泄漏。
Map并发安全吗?
map不是并发安全的,多线程读写会导致数据竞争和不确定的行为
Go 提供了 sync 包中的 sync.Map 类型,这是并发安全的类型
Go语⾔中的Channel是什么, 有哪些⽤途,如何处理阻塞
数据传递: 主要⽤于在goroutines之间传递数据,确保数据的安全传递和同步。
同步执⾏: 通过Channel可以实现在不同goroutines之间的同步执⾏,确保某个goroutine在另⼀个goroutine完成某个操作之前等待。
消息传递: 适⽤于实现发布-订阅模型或通过消息进⾏事件通知的场景。
多路复⽤: 使⽤ select语句,可以在多个Channel操作中选择⼀个⾮阻塞的执⾏,实现多路复⽤。
错误处理
Go错误处理机制
Go语⾔使⽤返回值来处理错误,函数通常返回两个值,⼀个是正常的返回值,另⼀个是 error类型的值,⽤于表示可能出现的错误。开发者需要显式地检查错误并进⾏处理,通过判断返回的 error值是否为 nil来确定函数是否执⾏成功。
Go中的错误是普通的值,是实现了 error接⼝的类型。
Go的错误处理机制在性能上通常更为⾼效,因为它不会引⼊额外的控制流程(异常栈的构建和查找等)
panic/recover
panic 是⼀个内建函数,⽤于引发运⾏时错误,通常表示程序遇到了不可恢复的错误。
当程序执⾏到 panic 语句时,它会⽴即停⽌当前函数的执⾏,并沿着函数调⽤栈向上搜寻,执⾏每个被调⽤函数的 defer 延迟函数(如果有的话),然后程序终⽌。
panic 通常⽤于表示程序遇到了⼀些致命错误,例如切⽚越界、除以零等。
recover 是⼀个内建函数,⽤于从 panic 引发的运⾏时错误中进⾏恢复。
recover 只能在 defer 延迟函数中使⽤,⽤于捕获 panic 的值,并防⽌程序因 panic 崩溃。
如果在 defer 函数中调⽤了 recover,并且程序处于 panic 状态,那么 recover 将返回 panic 的值,并且程序会从 panic 的地⽅继续执⾏。
defer
Q: 执⾏顺序
defer语句是按照后进先出(LIFO)的顺序执⾏的,即最后⼀个 defer语句会最先执⾏。
Q: 函数参数是在哪个时刻确定的?
defer语句中的函数参数在 defer 语句被执⾏时就已经确定了,⽽不是在函数实际调⽤时。因此,如果 defer语句中有函数参数,这些参数的值是在 defer语句执⾏时就会被计算并保留。
Q: 对性能有没有影响
defer语句的性能影响通常很⼩,因为它是在函数退出时执⾏的。但如果在循环中使⽤了⼤量的 defer语句,可能会导致性能问题,因为 defer语句的执⾏会被延迟到函数退出时,循环可能会在函数退出之前执⾏许多次。
Q: 在什么情况下会有问题?
如果在循环中使⽤ defer,并且 defer中引⽤了循环变量,由于 defer语句的延迟执⾏特性,可能导致循环结束后函数执⾏时使⽤的是最后⼀次循环变量的值。这被称为”defer在循环中的陷阱”。(引入闭包或增加局部变量)
Go实现面向对象
Go没有类的概念,⽽是通过结构体(struct)和接⼝(interface)来实现⾯向对象的特性。
结构体是⼀种⽤户定义的数据类型,可以包含字段(成员变量)和⽅法(成员函数)。
1
2
3
4
5
6
7
8 type Person struct {
Name string
Age int
}
// ⽅法
func (p *Person) SayHello() {
fmt.Println("Hello, my name is", p.Name)
}Go语⾔通过接⼝来定义对象的⾏为,⽽不是通过明确的继承关系。⼀个类型只要实现了接⼝定义的⽅法,就被视为实现了该接⼝。
1
2
3
4
5
6
7
8
9
10 type Speaker interface {
Speak()
}
type Person struct {
Name string
}
// Person 实现了 Speaker 接⼝
func (p *Person) Speak() {
fmt.Println("Hello, my name is", p.Name)
}
Go并发
进程、线程和协程都是并发编程的概念
进程是操作系统分配资源的基本单位,每个进程都有⾃⼰的独⽴内存空间,不同进程之间的数据不能直接共享, 通常通过进程间通信(IPC)来进⾏数据交换,例如管道、消息队列等。
线程是操作系统调度的最⼩执⾏单位,同⼀进程的不同线程共享相同的内存空间,可以直接访问共享数据。
协程是轻量级的⽤户态线程,由Go调度器进⾏管理,协程的创建和销毁⽐线程更为轻量,可以很容易地创建⼤量的协程。协程之间通过通信来共享数据,⽽不是通过共享内存。这通过使⽤通道(channel)等机制来实现。
进程是资源管理的基本单位,线程是程序执⾏的基本单位。
协程和线程的区别
线程和进程都是同步机制,⽽协程是异步机制。
线程是抢占式,⽽协程是⾮抢占式的。需要⽤户释放使⽤权切换到其他协程,因此同⼀时间其实只有⼀个协程拥有运⾏权,相当于单线程的能⼒。
⼀个线程可以有多个协程,⼀个进程也可以有多个协程。
协程不被操作系统内核管理,⽽完全是由程序控制。线程是被分割的CPU资源,协程是组织好的代码流程,线程是协程的资源。但协程不会直接使⽤线程,协程直接利⽤的是执⾏器关联任意线程或线程池。
协程能保留上⼀次调⽤时的状态。
并⾏和并发的区别
并发就是在⼀段时间内,多个任务都会被处理;但在某⼀时刻,只有⼀个任务 在执⾏。单核处理器可以做到并发。⽐如有两个进程 A 和 B,A 运⾏⼀个时间⽚之后,切换到 B,B 运⾏⼀个时间⽚之后⼜切换到 A。因为切换速度⾜够快,所以宏观上表现为在⼀段时间内能同时运⾏多个程序。
并⾏就是在同⼀时刻,有多个任务在执⾏。这个需要多核处理器才能完成,在微观上就能同时执⾏多条指令,不同的程序被放到不同的处理器上运⾏,这个是物理上的多个进程同时进⾏。
Go语⾔并发模型
Go语⾔的并发模型建⽴在goroutine和channel之上。其设计理念是共享数据通过通信⽽不是通过共享来实现
Goroutines 是Go中的轻量级线程,由Go运⾏时(runtime)管理。与传统线程相⽐,goroutines的创建和销毁开销很⼩。程序可以同时运⾏多个goroutines,它们共享相同的地址空间。
Goroutines之间的通信通过channel(通道)实现。通道提供了⼀种安全、同步的⽅式,⽤于在goroutines之间传递数据。使⽤通道可以避免多个goroutines同时访问共享数据⽽导致竞态条件的问题。
多路复⽤: select 语句允许在多个通道操作中选择⼀个执⾏。这种⽅式可以有效地处理多个通道的并发操作,避免了阻塞。
互斥锁和条件变量
Go提供了 sync包,其中包括 Mutex(互斥锁)等同步原语,⽤于在多个goroutines之间进⾏互斥访问共享资源。
sync 包还提供了 Cond(条件变量),⽤于在goroutines之间建⽴更复杂的同步。
原⼦操作: Go提供了 sync/atomic包,其中包括⼀系列原⼦性操作,⽤于在不使⽤锁的情况下进⾏安全的并发操作。
每个 goroutine 都有⾃⼰的独⽴栈空间,这使得它们之间的数据不容易互相⼲扰。与传统的多线程编程相⽐,使⽤goroutines 不需要开发者显式地进⾏线程的创建、销毁和同步。Go 运⾏时会⾃动处理这些事务。
处理阻塞的方式
缓冲通道,在创建通道时指定缓冲区⼤⼩,即创建⼀个缓冲通道。当缓冲区未满时,发送数据不会阻塞。当缓冲区未空时,接收数据不会阻塞。
select语句⽤于处理多个通道操作,可以⽤于避免阻塞。
使⽤ time.After创建⼀个定时器,可以在超时后执⾏特定的操作,避免永久阻塞。
select语句中使⽤ default分⽀,可以在所有通道都阻塞的情况下执⾏⾮阻塞的操作。
对同⼀个通道,发送/接收操作之间是互斥的
- 发送操作和接收操作都是原⼦的,保证通道中元素值的完整性和通道操作的唯⼀性。
- **发送操作在完全完成之前会被阻塞,接收操作也是。在通道完成发送操作之后,runtime系统会通知这句代码所在的goroutine,解除阻塞,以使它去争取继续运⾏代码的机会。如此阻塞代码也是为了实现操作的互斥和元素值的完整。
Mutex
什么是互斥锁(mutex)?在什么情况下会⽤到它们?
互斥锁是⼀种⽤于控制对共享资源访问的同步机制。它确保在任意时刻只有⼀个线程能够访问共享资源,避免了多个线程同时对资源进⾏写操作导致的数据竞争和不⼀致性。
在并发编程中,多个线程(或者Goroutines)可能同时访问共享的数据,如果不进⾏同步控制,可能导致以下问题:
竞态条件(Race Condition): 多个线程同时修改共享资源,导致最终结果依赖于执⾏时机,可能引发不确定的⾏为。
数据不⼀致性: 多个线程同时读写共享资源,可能导致数据不⼀致,破坏了程序的正确性。
互斥锁通过在临界区(对共享资源的访问区域)中使⽤锁来解决这些问题。基本上,当⼀个线程获得了互斥锁时,其他线程需要等待该线程释放锁后才能获得锁。这确保了在任⼀时刻只有⼀个线程能够进⼊临界区。
注:在使⽤互斥锁时,要确保在临界区内的代码执⾏时间较短,以减⼩锁的持有时间,从⽽提⾼程序的并发性能。过⻓的锁持有时间可能导致其他线程被阻塞,降低并发性。
Mutex有两种模式
Normal: 锁的获取是⾮公平的,等待锁的 Goroutine 不保证按照FIFO的顺序获得锁。新到来的 Goroutine 有可能在等待时间较⻓的 Goroutine 之前获得锁。
Starvation: 系统保证等待锁的 Goroutine 按照⼀定的公平原则获得锁,避免某些 Goroutine ⻓时间⽆法获取锁的情况。
正常模式下的性能是最好的,goroutine 可以连续多次获取锁,饥饿模式解决了取锁公平的问题,但是性能会下降,这其实是性能和公平的一个平衡模式。
Mutex有⼏种状态
mutexLocked — 锁定状态;
mutexWoken — 从正常模式被唤醒;
mutexStarving — 饥饿状态;
waitersCount — 当前互斥锁上等待的 Goroutine 个数;
锁的类型
- **
Lock**:阻塞调用,确保线程获得锁。- **
TryLock**:非阻塞调用,立即返回,适合需要尝试获取锁而不想等待的场景。
Mutex允许自旋的条件
锁已被占用,并且锁不处于饥饿模式。
积累的自旋次数小于最大自旋次数(active_spin=4)。
CPU 核数大于 1。
有空闲的 P。
当前 Goroutine 所挂载的P下,本地待运行队列为空。
自旋锁的优缺点
- 优点:在锁竞争不激烈且持锁时间短的情况下,性能可能优于传统的互斥锁。实现相对简单,易于理解。
- 缺点:对于长时间持锁的情况,可能会导致 CPU 资源浪费。自旋锁不适合高延迟的操作,因为它会阻塞其他线程,导致性能下降。
RWMutex
RWMutex 是单写多读锁,该锁可以加多个读锁或者一个写锁
RWMutex 类型变量的零值是一个未锁定状态的互斥锁
RWMutex 在首次被使用之后就不能再被拷贝
RWMutex 的读锁或写锁在未锁定状态,解锁操作都会引发 panic
RWMutex 读锁不要用于递归调用,比较容易产生死锁
RWMutex 的锁定状态与特定的 goroutine 没有关联。一个 goroutine 可以 RLock(Lock),另一个 goroutine 可以 RUnlock(Unlock)
写锁被解锁后,所有因操作锁定读锁而被阻塞的 goroutine 会被唤醒,并都可以成功锁定读锁
读锁被解锁后,在没有被其他读锁锁定的前提下,所有因操作锁定写锁而被阻塞的 Goroutine,其中等待时间最长的一个 Goroutine 会被唤醒
Sync.cond
sync.Cond 适合用于需要等待和通知机制的场景,尤其是在复杂的同步需求、资源可用性和状态变化的情况下。
Broadcast:唤醒所有等待cond的goroutine
Signal:唤醒一个等待cond的gorouting
以上两个操作可以不加锁
WaitGroup
WaitGroup 主要维护了一个请求计数器 v和一个等待计数器 w,二者组成一个 64bit的值,请求计数器占高 32bit,等待计数器占低32bit。
每次 Add执行,请求计数器 v加 1,Done方法执行,等待计数器减1,v为0时通过信号量唤醒 Wait()。
Channel
⽆缓冲的channel和有缓冲的channel的区别?
⽆缓冲channel:发送的数据如果没有被接收⽅接收,那么发送⽅阻塞;如果⼀直接收不到发送⽅的数据,接收⽅阻塞;
有缓冲channel:发送⽅在缓冲区满的时候阻塞,接收⽅不阻塞;接收⽅在缓冲区为空的时候阻塞,发送⽅不阻塞。
Go什么时候发⽣阻塞?阻塞时调度器会怎么做。
⽤于原⼦、互斥量或通道操作导致goroutine阻塞,调度器将把当前阻塞的goroutine从本地运⾏队列LRQ换出,并重新调度其它goroutine;
由于⽹络请求和IO导致的阻塞,Go提供了⽹络轮询器(Netpoller)来处理,后台⽤epoll等技术实现IO多路复⽤。
当goroutine读写channel发⽣阻塞时,会调⽤gopark函数,该G脱离当前的M和P,调度器将新的G放⼊当前M。
当某个G由于系统调⽤陷⼊内核态,该P就会脱离当前M,此时P会更新⾃⼰的状态为Psyscall,M与G相互绑定,进⾏系统调⽤。结束以后,若该P状态还是Psyscall,则直接关联该M和G,否则使⽤闲置的处理器处理该G。
当某个G在P上运⾏的时间超过10ms时候,或者P处于Psyscall状态过⻓等情况就会调⽤retake函数,触发新的调度。
由于是协作式调度,该G会主动让出当前的P(通过GoSched),更新状态为Grunnable,该P会调度队列中的G运⾏。
channel结构
Buffer:有缓冲的channel内部有一个ring buffer,用于存储发送的数据
Sendx:发送操作在缓冲区中的位置,指向下一个可以发送数据的位置
Recvx:接受操作在缓冲区中的位置,指向下一个可以接收数据的位置
Sendq:发送操作因为缓冲区已满而阻塞时,发送的goroutine会被放入发送队列,其通常是一个双链表
Recv:接收操作因为缓冲区已满而阻塞时,接收的goroutine会被放入接收队列,其通常是一个双链表
内存泄漏
暂时性内存泄漏:
获取⻓字符串中的⼀段导致⻓字符串未释放
获取⻓slice中的⼀段导致⻓slice未释放
在⻓slice新建slice导致泄漏
string相⽐切⽚少了⼀个容量的cap字段,可以把string当成⼀个只读的切⽚类型。获取⻓string或者切⽚中的⼀段内容,由于新⽣成的对象和⽼的string或者切⽚共⽤⼀个内存空间,会导致⽼的string和切⽚资源暂时得不到释放,造成短暂的内存泄漏
GC机制
Go1.3之前采⽤标记清除法, Go1.3之后采⽤三⾊标记法,Go1.8采⽤三⾊标记法+混合写屏障。
标记清除法
初始版本的Go语⾔使⽤了⼀个基于标记-清扫(Mark-Sweep)算法的垃圾回收器。
⾸先从根对象(如全局变量、栈中的引⽤等)出发,标记所有可达对象。这⼀过程通常使⽤深度优先搜索或⼴度优先搜索进⾏。标记的⽅式通常是将对象的标记位从未标记改为已标记。所有的可达对象都被标记为“活动”或“存活”。
在清扫阶段,遍历整个堆内存,将未被标记的对象视为垃圾,即不再被引⽤。所有未被标记的对象都将被回收,它们的内存将被释放,以便后续的内存分配。
标记清除算法执⾏完清扫阶段后,可能会产⽣内存碎⽚,即⼀些被回收的内存空间可能是不连续的。为了解决这个问题可能会进⾏内存碎⽚整理。
优势:
- 回收不再使⽤的内存
劣势:
- 清扫阶段遍历整个堆内存可能会引起⼀定程度的停顿
- 只关注“存活”和“垃圾”两种状态,不涉及内存分配的具体位置,可能导致内存碎⽚的产⽣。
三⾊标记法
将对象分为三种颜⾊:⽩⾊、灰⾊、和⿊⾊。初始时,所有对象都被标记为⽩⾊,表示它们都是未被访问的垃圾对象。
流程:
根对象开始搜索(全局变量、栈上的对象以及其他⼀些持有对象引⽤的地⽅。)
所有根对象标记为灰⾊,表示它们是待处理的对象。
标记阶段:从灰⾊对象开始,垃圾回收器遍历对象的引⽤关系,将其引⽤的对象标记为灰⾊,然后将该对象标记为⿊⾊。这个过程⼀直进⾏,直到所有可达对象都被标记为⿊⾊。
并发标记:在标记阶段,垃圾回收器采⽤并发标记的⽅式,与程序的执⾏同时进⾏。这意味着程序的执⾏不会因为垃圾回收⽽停顿,从⽽减⼩了对程序性能的影响。
清扫阶段:在标记完成后,垃圾回收器会扫描堆中的所有对象,将未被标记的对象回收。这些未被标记的对象被认为是不可达的垃圾。
内存返还:垃圾回收完成后,系统中的内存得以回收并⽤于新的对象分配。
GC触发:垃圾回收的触发条件通常是在分配新对象时,如果达到⼀定的内存分配阈值,就会触发垃圾回收。
另外,⼀些特定的事件(如系统调⽤、⽹络阻塞等)也可能触发垃圾回收。
三⾊标记法+混合写屏障
这种⽅法有⼀个缺陷,如果对象的引⽤被⽤户修改了,那么之前的标记就⽆效了。因此Go采⽤了写屏障技术,当对象新增或者更新会将其着⾊为灰⾊。
⼀次完整的GC分为四个阶段:
- 准备标记(需要STW),开启写屏障。
- 开始标记
- 标记结束(STW),关闭写屏障
- 清理(并发)
基于插⼊写屏障和删除写屏障在结束时需要STW来重新扫描栈,带来性能瓶颈。混合写屏障分为以下四步:
- GC开始时,将栈上的全部对象标记为⿊⾊(不需要⼆次扫描,⽆需STW)
- GC期间,任何栈上创建的新对象均为⿊⾊
- 被删除引⽤的对象标记为灰⾊
- 被添加引⽤的对象标记为灰⾊
GC调优
控制内存分配的速度,限制 Goroutine 的数量,从⽽提⾼赋值器对 CPU的利⽤率。
减少并复⽤内存,例如使⽤ sync.Pool 来复⽤需要频繁创建临时对象,例如提前分配⾜够的内存来降低多余的拷⻉。
需要时,增⼤ GOGC 的值,降低 GC 的运⾏频率。
GMP

结构
G:
- g是go对协程的抽象
- g有自己的运行栈、状态以及执行的任务函数
- g需要绑定到processor才能执行,在g的视角中,p就是它的CPU
P:
- p是processor,是golang中的调度器
- p是GMP的中枢,实现g和m之间的动态结合
- g只有被p调度才能执行
- 对m而言,p是其执行代理,为其提供必要信息的同时隐藏了复杂的调度细节
- p的数量决定了g最大并行数量,用户通过GOMAXPROCS(不超过CPU内核数)设定
M:
- m即machine,是golang对线程的抽象
- m不能直接执行g,而是先河p绑定,由其实现代理
- 由于p的存在,m无需和g绑定,也无需记录g的状态信息,因此g在生命周期内可以跨m执行
work stealing
获取P 本地队列,如果从绑定 P的 本地 runq 上找不到可执行的 g:
尝试从全局链表中获取g
如果获取不到则从 netpoll 和事件池里拿
最后会从别的 P 里偷任务
P此时去唤醒一个 M。
P 继续执行其它的程序。
M 寻找是否有空闲的 P,如果有则将该G 对象移动到它本身。
接下来 M 执行一个调度循环(调用 G 对象->执行->清理线程→继续找新的 Goroutine 执行)
调度机制
调度过程中存在的阻塞
- I/O select
- block on syscall
- channel
- 等待锁
- runtime.Gosched
CAP 理论,为什么不能同时满⾜
分布式系统设计中的三个基本属性:⼀致性(Consistency)、可⽤性(Availability)、分区容错性(Partition Tolerance)。
- ⼀致性(Consistency):
⼀致性要求系统在所有节点上的数据是⼀致的。即,如果在⼀个节点上修改了数据,那么其他节点应该⽴即看到这个修改。这意味着在任何时刻,不同节点上的数据应该保持⼀致。
- 可⽤性(Availability):
可⽤性要求系统能够对⽤户的请求做出响应,即使在出现节点故障的情况下仍然保持可⽤。可⽤性意味着系统在出现故障时仍然能够提供服务,尽管可能是部分服务。
- 分区容错性(Partition Tolerance):
分区容错性是指系统在⾯对⽹络分区的情况下仍能够正常⼯作。即,当节点之间的⽹络出现故障或⽆法通信时,系统仍能够保持⼀致性和可⽤性。
CAP 理论提出的是在分布式系统中这三个属性不能同时被满⾜。这是由于在分布式系统中,⽹络的不确定性和延迟会导致⽆法同时满⾜⼀致性、可⽤性和分区容错性。
defer
- 一个函数后定义多条defer时会将defer函数压入栈内,遵循LIFO。
- defer执行的函数为nil时会产生panic
- defer函数入栈时保存形参
- 后⾯定义的函数可能会依赖前⾯的资源,所以要先执⾏。如果前⾯先执⾏,释放掉这个依赖,那后⾯的函数就找不到它的依赖了。
- 通常用于打开、关闭连接,加锁、释放锁。
defer函数定义时,对外部变量的引⽤⽅式有两种
在作为函数参数的时候,在defer定义时就把值传递给defer,并被缓存起来。
作为闭包引⽤,则会在defer真正调⽤的时候,根据整个上下⽂去确定当前的值。
3、defer后⾯的语句在执⾏的时候,函数调⽤的参数会被保存起来,也就是复制⼀份。
在真正执⾏的时候,实际上⽤到的是复制的变量,也就是说,如果这个变量是⼀个”值类型”,那他就和定义的时候是⼀致的,如果是⼀个”引⽤”,那么就可能和定义的时候的值不⼀致
Go内存对齐
CPU 访问内存时,并不是逐个字节访问,⽽是以字⻓(word size)为单位访问。⽐如 32 位的 CPU ,字⻓为 4 字节,那么 CPU 访问内存的单位也是 4 字节。
CPU 始终以字⻓访问内存,如果不进⾏内存对⻬,很可能增加 CPU 访问内存的次数,例如:
变量 a、b 各占据 3 字节的空间,内存对⻬后,a、b 占据 4 字节空间,CPU 读取 b 变量的值只需要进⾏⼀次内存访问。如果不进⾏内存对⻬,CPU 读取 b 变量的值需要进⾏ 2 次内存访问。第⼀次访问得到 b 变量的第 1 个字节,第⼆次访问得到 b 变量的后两个字节。
也可以看到,内存对⻬对实现变量的原⼦性操作也是有好处的,每次内存访问是原⼦的,如果变量的⼤⼩不超过字⻓,那么内存对⻬后,对该变量的访问就是原⼦的,这个特性在并发场景下⾄关重要。
合理的内存对⻬可以提⾼内存读写的性能,并且便于实现变量操作的原⼦性。
原子操作
- 原子操作即是执行过程中不能被中断的操作
- CPU针对某个值同时只能执行一次原子操作
为了实现这样的严谨性,原子操作仅会由一个独立的 CPU 指令代表和完成。原子操作是无锁的,常常直接通过 CPU 指令直接实现。 事实上,其它同步技术的实现常常依赖于原子操作。
原子操作和锁的区别
原子操作由底层硬件支持
锁由操作系统的调度器实现
锁原该用于保护一段逻辑、一个变量,原子操作更适合更新一个复合对象
About this Post
This post is written by ByronGu, licensed under CC BY-NC 4.0.