面视题总结(二)

uintptrunsafe.Pointer的区别 #

  • unsafe.Pointer只是单纯的通用指针类型,用于转换不同类型指针,它不可以参与指针运算;
  • 而uintptr是用于指针运算的,GC 不把 uintptr 当指针,也就是说 uintptr 无法持有对象, uintptr 类型的目标会被回收;
  • unsafe.Pointer 可以和 普通指针 进行相互转换;
  • unsafe.Pointer 可以和 uintptr 进行相互转换。

举例

  • 通过一个例子加深理解,接下来尝试用指针的方式给结构体赋值。
package main

import (
 "fmt"
 "unsafe"
)

type W struct {
 b int32
 c int64
}

func main() {
 var w *W = new(W)
 //这时w的变量打印出来都是默认值0,0
 fmt.Println(w.b,w.c)

 //现在我们通过指针运算给b变量赋值为10
 b := unsafe.Pointer(uintptr(unsafe.Pointer(w)) + unsafe.Offsetof(w.b))
 *((*int)(b)) = 10
 //此时结果就变成了10,0
 fmt.Println(w.b,w.c)
}
  • uintptr(unsafe.Pointer(w)) 获取了 w 的指针起始值
  • unsafe.Offsetof(w.b) 获取 b 变量的偏移量
  • 两个相加就得到了 b地址值,将通用指针 Pointer 转换成具体指针 ((*int)(b)),通过 * 符号取值,然后赋值。*((*int)(b)) 相当于把 (*int)(b) 转换成 int了,最后对变量重新赋值成 10,这样指针运算就完成了。

怎么避免内存逃逸? #

内存逃逸(Escape Analysis) 是指编译器在编译阶段分析变量的生命周期,决定将其分配在栈(stack)还是堆(heap)上。如果变量逃逸到堆上,会增加 GC 压力,降低性能。

变量逃逸到堆上的常见场景

  1. 返回局部变量的指针:

    func foo() *int {
        x := 42 // x 逃逸到堆,因为返回值是指针
        return &x
    }
    
  2. 被闭包捕获的变量:

    func closure() func() int {
        y := 10
        return func() int { return y } // y 逃逸到堆
    }
    
  3. 发送指针到 Channel 或保存到全局变量:

    var global *int
    func save() {
        z := 5
        global = &z // z 逃逸到堆
    }
    
  4. 动态大小的对象(如大尺寸的 slice/map):

    func bigSlice() {
        s := make([]int, 1e6) // 可能逃逸到堆
    }
    

2. 避免内存逃逸的优化方法

(1) 尽量使用值传递而非指针

  • 优化前(逃逸):

    func getUser() *User {
        u := User{Name: "Alice"}
        return &u // u 逃逸到堆
    }
    
  • 优化后(栈分配):

    func getUser() User { // 返回值改为值类型
        return User{Name: "Alice"} // 分配在栈上
    }
    

(2) 避免返回局部变量的指针

  • 优化前(逃逸):

    func getID() *int {
        id := 100
        return &id
    }
    
  • 优化后(通过参数传递):

    func getID(id *int) { // 由调用方提供内存
        *id = 100
    }
    

(3) 控制闭包捕获的变量

  • 优化前(逃逸):

    func counter() func() int {
        count := 0
        return func() int { // count 逃逸到堆
            count++
            return count
        }
    }
    
  • 优化后(通过参数传递状态):

    func counter(count int) func() int {
        return func() int { // count 通过参数传递,可能不逃逸
            return count + 1
        }
    }
    

(4) 预分配或复用对象

  • 优化前(逃逸):

    func process() {
        buf := make([]byte, 1024) // 可能逃逸
    }
    
  • 优化后(全局复用):

    var bufPool = sync.Pool{
        New: func() interface{} { return make([]byte, 1024) },
    }
    func process() {
        buf := bufPool.Get().([]byte)
        defer bufPool.Put(buf) // 复用内存
    }
    

(5) 避免大对象分配

  • 优化前(逃逸):

    func bigData() *[1e6]int {
        var data [1e6]int
        return &data // 大数组逃逸到堆
    }
    
  • 优化后(改用 slice 或分批处理):

    func bigData() []int {
        return make([]int, 1000) // 小对象可能不逃逸
    }
    

Mutex 正常模式和饥饿模式 #

正常模式(⾮公平锁)

正常模式下,所有等待锁的 goroutine 按照 FIFO(先进先出)顺序等待。唤醒的 goroutine 不会直接拥有锁,⽽是会和新请求锁的 goroutine 竞争锁的拥有。新请求锁的 goroutine 具有优势:它正在 CPU上执⾏,⽽且可能有好⼏个,所以刚刚唤醒的 goroutine 有很⼤可能在锁竞争中失败。在这种情况下,这个被唤醒的 goroutine 会加⼊到等待队列的前⾯。如果⼀个等待的 goroutine 超过1ms没有获取锁,那么它将会把锁转变为饥饿模式。

饥饿模式(公平锁)

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

总结

对于两种模式,正常模式下的性能是最好的,goroutine 可以连续多次获取锁,饥饿模式解决了取锁公平的问题,但是性能会下降,其实是性能和公平的⼀个平衡模式。

什么操作叫做原⼦操作 #

⼀个或者多个操作在 CPU执⾏过程中不被中断的特性,称为原⼦性(atomicity)。这些操作对外表现成⼀个不可分割的整体,他们要么都执⾏,要么都不执⾏,外界不会看到他们只执⾏到⼀半的状态。⽽在现实世界中,CPU 不可能不中断的执⾏⼀系列操作,但如果我们在执⾏多个操作时,能让他们的中间状态对外不可⻅,那我们就可以宣城他们拥有了“不可分割”的原⼦性。

在 Go中,⼀条普通的赋值语句其实不是⼀个原⼦操作。列如,在32 位机器上写 int64 类型的变量就会有中间状态,因为他会被拆成两次写操作(MOV)——写低32 位和写⾼32 位。

原⼦操作和锁的区别 #

原⼦操作由底层硬件⽀持,⽽锁则由操作系统的调度器实现。

内存泄漏题 #

func main() {
 num := 6
 for index := 0; index < num; index++ {
  resp, _ := http.Get("https://www.baidu.com")
  _, _ = ioutil.ReadAll(resp.Body)
 }
 fmt.Printf("此时goroutine个数= %d\n", runtime.NumGoroutine())
}

上面这道题在不执行resp.Body.Close()的情况下,泄漏了吗?如果泄漏,泄漏了多少个goroutine?

因为执行了ioutil.ReadAll()把内容都读出来了,连接得以复用,因此只泄漏了一个读goroutine和一个写goroutine,最后加上main goroutine,所以答案就是3个goroutine

具体来说:

  1. 写协程:当调用 http.Get() 时,底层会创建一个网络连接并启动一个 goroutine 来负责写入 HTTP 请求。这个 goroutine 会发送 HTTP 请求头和数据(如果有的话)到服务器。
  2. 读协程:同时,http.Get() 会启动另一个 goroutine 来等待并读取服务器的响应。这是因为网络 I/O 是阻塞操作,使用单独的 goroutine 可以避免阻塞主程序。

当你调用 ioutil.ReadAll(resp.Body) 时,你是在读取响应体,这个操作会与那个负责读取的 goroutine 进行交互。

http.Get 默认使用 DefaultTransport 管理连接

DefaultTransport 的作用是根据需要建立网络连接并缓存它们以供后续调用重用。

go栈扩容和栈缩容,连续栈的缺点 #

https://segmentfault.com/a/1190000019570427

为什么P的逻辑不直接加在M上 #

主要还是因为M其实是内核线程,内核只知道自己在跑线程,而golang的运行时(包括调度,垃圾回收等)其实都是用户空间里的逻辑。操作系统内核哪里还知道,也不需要知道用户空间的golang应用原来还有那么多花花肠子。这一切逻辑交给应用层自己去做就好,毕竟改内核线程的逻辑也不合适啊。

GMP hand off 机制 #

当本线程 M因为 G进⾏的系统调⽤阻塞时,线程释放绑定的 P,把 P转移给其他空闲的 M’执⾏。当发⽣上线⽂切换时,需要对执⾏现场进⾏保护,以便下次被调度执⾏时进⾏现场恢复。Go调度器 M的栈保存在 G对象上,只需要将 M所需要的寄存器(SP、PC等)保存到 G对象上就可以实现现场保护。当这些寄存器数据被保护起来,就随时可以做上下⽂切换了,在中断之前把现场保存起来。如果此时 G任务还没有执⾏完,M可以将任务重新丢到 P的任务队列,等待下⼀次被调度执⾏。当再次被调度执⾏时,M通过访问 G的 vdsoSP、vdsoPC寄存器进⾏现场恢复(从上次中断位置继续执⾏)。

数据库外连接与内连接 #

在数据库查询中,连接(JOIN)是将多个表中的数据组合在一起的操作。主要的连接类型包括内连接和外连接。

内连接(INNER JOIN) #

内连接是最常用的连接类型,它只返回两个表中匹配的行。

特点:

  • 只显示两个表中都存在的记录
  • 不匹配的记录会被排除
  • 语法:SELECT ... FROM table1 INNER JOIN table2 ON table1.column = table2.column

示例:

SELECT orders.order_id, customers.customer_name
FROM orders
INNER JOIN customers ON orders.customer_id = customers.customer_id;

外连接(OUTER JOIN) #

外连接返回一个表中的所有记录,以及另一个表中的匹配记录(如果存在)。外连接分为三种:

1. 左外连接(LEFT OUTER JOIN 或 LEFT JOIN) #

  • 返回左表的所有记录,即使右表中没有匹配
  • 右表无匹配时显示NULL值
  • 语法:SELECT ... FROM table1 LEFT JOIN table2 ON table1.column = table2.column

2. 右外连接(RIGHT OUTER JOIN 或 RIGHT JOIN) #

  • 返回右表的所有记录,即使左表中没有匹配
  • 左表无匹配时显示NULL值
  • 语法:SELECT ... FROM table1 RIGHT JOIN table2 ON table1.column = table2.column

3. 全外连接(FULL OUTER JOIN 或 FULL JOIN) #

  • 返回左右两表的所有记录
  • 无匹配的部分显示NULL值
  • 语法:SELECT ... FROM table1 FULL JOIN table2 ON table1.column = table2.column

示例(左外连接):

SELECT customers.customer_name, orders.order_id
FROM customers
LEFT JOIN orders ON customers.customer_id = orders.customer_id;

关键区别 #

特性 内连接 左外连接 右外连接 全外连接
返回左表匹配行
返回右表匹配行
返回左表不匹配行
返回右表不匹配行

选择哪种连接取决于您需要从查询中获取什么样的数据。

单节点的 MySQL 事务依靠的是 undo 和 redo 日志。那么在数据库集群中,在 A 节点中修改了数据,在 B 节点中添加了新的数据,那么怎么能让这两个节点的事务联合成一个分布式的事务呢? #

分布式事务的实现机制 #

在数据库集群中实现分布式事务确实比单节点复杂得多。您提到的场景(节点A修改数据,节点B添加新数据)需要分布式事务协调机制来保证ACID特性。以下是主要解决方案:

两阶段提交协议 (2PC) #

最常见的分布式事务实现方式:

  1. 准备阶段
    • 事务协调者询问所有参与者(A和B节点)是否可以提交
    • 各参与者执行事务操作但不提交,记录undo/redo日志
    • 参与者回复"可以提交"或"不能提交"
  2. 提交阶段
    • 如果所有参与者都同意,协调者发送提交命令
    • 如果有任何参与者反对,协调者发送回滚命令
    • 参与者根据命令执行最终操作

其他分布式事务方案 #

  1. 三阶段提交 (3PC)
    • 在2PC基础上增加预提交阶段,减少阻塞时间
    • 提高了可用性但实现更复杂
  2. TCC模式 (Try-Confirm-Cancel)
    • Try阶段:预留资源
    • Confirm阶段:确认执行业务
    • Cancel阶段:取消预留资源
  3. SAGA模式
    • 将大事务拆分为多个本地事务
    • 每个本地事务有对应的补偿事务
    • 适合长事务场景