September 23, 2024

Golang基础内容

Go语言的特点和优势

  • 语法简单
  • 支持轻量级线程(gorouting)和通信(channel),高并发
  • 内置垃圾回收

Go和Java对比

  1. Java使⽤⼴泛,但是Go⽐Java更适合⾼并发和轻量级的应⽤

  2. Java通过线程和锁来处理并发, Goroutines和channels是Go语⾔的并发特性的核⼼

  3. Java是⼀⻔功能丰富、⾯向对象的语⾔,⽀持⾯向对象编程、泛型等⾼级特性。 Go语⾔的设计注重简洁和清晰,具有简单的语法和类型系统。它摒弃了⼀些复杂的特性,强调代码的可读性。

  4. Go语⾔具有垃圾回收机制,开发者⽆需⼿动管理内存。Java同样拥有垃圾回收机制,这减轻了开发者的负担,但在⼀些情况下可能引⼊⼀些不可控的暂停。

  5. 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并不会初始化值

数组和切片的区别

数组

固定⻓度,声明数组时指定⻓度,且不能更改,值拷贝。

数组的元素在内存中是顺序存储的,分配在⼀块连续的内存区域

切⽚

切⽚的⻓度可以动态调整,⽽且可以不指定⻓度引用传递

切⽚本身不存储元素,⽽是引⽤⼀个底层数组。切⽚的底层数组会在需要时进⾏动态扩展。

切片扩容机制

Screenshot 2025-03-13 at 14.08.12

实际上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扩容机制

  1. 计算新的存储桶数量:当 map的元素数量达到负载因⼦(load factor)的上限时,会触发扩容。新的存储桶数量通常会是当前存储桶数量的两倍。

  2. 分配新的存储桶和散列数组:创建新的存储桶和散列数组,⼤⼩为新的存储桶数量。这个过程会涉及到内存分配。

  3. 重新散列元素:遍历当前 map的每个存储桶,将其中的元素重新散列到新的存储桶中。这⼀步是为了保持元素在新的存储桶中的顺序。

  4. 切换到新的存储桶和散列数组:将 map的内部数据结构指向新的存储桶和散列数组。这个过程是原⼦的,以确保在切换期间不会影响并发访问。

  5. 释放旧的存储桶和散列数组:释放旧的存储桶和散列数组的内存空间。这个过程是为了避免内存泄漏。

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. 结构体是⼀种⽤户定义的数据类型,可以包含字段(成员变量)和⽅法(成员函数)。

    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)
    }
  2. 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 运⾏时会⾃动处理这些事务。

处理阻塞的方式

  1. 缓冲通道,在创建通道时指定缓冲区⼤⼩,即创建⼀个缓冲通道。当缓冲区未满时,发送数据不会阻塞。当缓冲区未空时,接收数据不会阻塞。

  2. select语句⽤于处理多个通道操作,可以⽤于避免阻塞。

  3. 使⽤ time.After创建⼀个定时器,可以在超时后执⾏特定的操作,避免永久阻塞。

  4. select语句中使⽤ default分⽀,可以在所有通道都阻塞的情况下执⾏⾮阻塞的操作。

对同⼀个通道,发送/接收操作之间是互斥的

  1. 发送操作和接收操作都是原⼦的,保证通道中元素值的完整性和通道操作的唯⼀性。
  2. **发送操作在完全完成之前会被阻塞,接收操作也是。在通道完成发送操作之后,runtime系统会通知这句代码所在的goroutine,解除阻塞,以使它去争取继续运⾏代码的机会。如此阻塞代码也是为了实现操作的互斥和元素值的完整。

Mutex

什么是互斥锁(mutex)?在什么情况下会⽤到它们?

  • 互斥锁是⼀种⽤于控制对共享资源访问的同步机制。它确保在任意时刻只有⼀个线程能够访问共享资源,避免了多个线程同时对资源进⾏写操作导致的数据竞争和不⼀致性。

  • 在并发编程中,多个线程(或者Goroutines)可能同时访问共享的数据,如果不进⾏同步控制,可能导致以下问题:

    1. 竞态条件(Race Condition): 多个线程同时修改共享资源,导致最终结果依赖于执⾏时机,可能引发不确定的⾏为。

    2. 数据不⼀致性: 多个线程同时读写共享资源,可能导致数据不⼀致,破坏了程序的正确性。

  • 互斥锁通过在临界区(对共享资源的访问区域)中使⽤锁来解决这些问题。基本上,当⼀个线程获得了互斥锁时,其他线程需要等待该线程释放锁后才能获得锁。这确保了在任⼀时刻只有⼀个线程能够进⼊临界区。

  • 注:在使⽤互斥锁时,要确保在临界区内的代码执⾏时间较短,以减⼩锁的持有时间,从⽽提⾼程序的并发性能。过⻓的锁持有时间可能导致其他线程被阻塞,降低并发性。

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分为四个阶段:

  1. 准备标记(需要STW),开启写屏障。
  2. 开始标记
  3. 标记结束(STW),关闭写屏障
  4. 清理(并发)

基于插⼊写屏障和删除写屏障在结束时需要STW来重新扫描栈,带来性能瓶颈。混合写屏障分为以下四步:

  1. GC开始时,将栈上的全部对象标记为⿊⾊(不需要⼆次扫描,⽆需STW)
  2. GC期间,任何栈上创建的新对象均为⿊⾊
  3. 被删除引⽤的对象标记为灰⾊
  4. 被添加引⽤的对象标记为灰⾊

GC调优

  • 控制内存分配的速度,限制 Goroutine 的数量,从⽽提⾼赋值器对 CPU的利⽤率。

  • 减少并复⽤内存,例如使⽤ sync.Pool 来复⽤需要频繁创建临时对象,例如提前分配⾜够的内存来降低多余的拷⻉。

  • 需要时,增⼤ GOGC 的值,降低 GC 的运⾏频率。

GMP

GMP模型.drawio

结构

G:

  1. g是go对协程的抽象
  2. g有自己的运行栈、状态以及执行的任务函数
  3. g需要绑定到processor才能执行,在g的视角中,p就是它的CPU

P:

  1. p是processor,是golang中的调度器
  2. p是GMP的中枢,实现g和m之间的动态结合
  3. g只有被p调度才能执行
  4. 对m而言,p是其执行代理,为其提供必要信息的同时隐藏了复杂的调度细节
  5. p的数量决定了g最大并行数量,用户通过GOMAXPROCS(不超过CPU内核数)设定

M:

  1. m即machine,是golang对线程的抽象
  2. m不能直接执行g,而是先河p绑定,由其实现代理
  3. 由于p的存在,m无需和g绑定,也无需记录g的状态信息,因此g在生命周期内可以跨m执行

work stealing

获取P 本地队列,如果从绑定 P的 本地 runq 上找不到可执行的 g:

  1. 尝试从全局链表中获取g

  2. 如果获取不到则从 netpoll 和事件池里拿

  3. 最后会从别的 P 里偷任务

  4. P此时去唤醒一个 M。

  5. P 继续执行其它的程序。

  6. M 寻找是否有空闲的 P,如果有则将该G 对象移动到它本身。

  7. 接下来 M 执行一个调度循环(调用 G 对象->执行->清理线程→继续找新的 Goroutine 执行)

调度机制

调度过程中存在的阻塞

CAP 理论,为什么不能同时满⾜

分布式系统设计中的三个基本属性:⼀致性(Consistency)、可⽤性(Availability)、分区容错性(Partition Tolerance)。

  1. ⼀致性(Consistency):

⼀致性要求系统在所有节点上的数据是⼀致的。即,如果在⼀个节点上修改了数据,那么其他节点应该⽴即看到这个修改。这意味着在任何时刻,不同节点上的数据应该保持⼀致。

  1. 可⽤性(Availability):

可⽤性要求系统能够对⽤户的请求做出响应,即使在出现节点故障的情况下仍然保持可⽤。可⽤性意味着系统在出现故障时仍然能够提供服务,尽管可能是部分服务。

  1. 分区容错性(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 指令直接实现。 事实上,其它同步技术的实现常常依赖于原子操作。

原子操作和锁的区别

About this Post

This post is written by ByronGu, licensed under CC BY-NC 4.0.

#Golang