uintptr和unsafe.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 压力,降低性能。
变量逃逸到堆上的常见场景:
-
返回局部变量的指针:
func foo() *int { x := 42 // x 逃逸到堆,因为返回值是指针 return &x }
-
被闭包捕获的变量:
func closure() func() int { y := 10 return func() int { return y } // y 逃逸到堆 }
-
发送指针到 Channel 或保存到全局变量:
var global *int func save() { z := 5 global = &z // z 逃逸到堆 }
-
动态大小的对象(如大尺寸的 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
。
具体来说:
- 写协程:当调用
http.Get()
时,底层会创建一个网络连接并启动一个 goroutine 来负责写入 HTTP 请求。这个 goroutine 会发送 HTTP 请求头和数据(如果有的话)到服务器。- 读协程:同时,
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) #
最常见的分布式事务实现方式:
- 准备阶段:
- 事务协调者询问所有参与者(A和B节点)是否可以提交
- 各参与者执行事务操作但不提交,记录undo/redo日志
- 参与者回复"可以提交"或"不能提交"
- 提交阶段:
- 如果所有参与者都同意,协调者发送提交命令
- 如果有任何参与者反对,协调者发送回滚命令
- 参与者根据命令执行最终操作
其他分布式事务方案 #
- 三阶段提交 (3PC):
- 在2PC基础上增加预提交阶段,减少阻塞时间
- 提高了可用性但实现更复杂
- TCC模式 (Try-Confirm-Cancel):
- Try阶段:预留资源
- Confirm阶段:确认执行业务
- Cancel阶段:取消预留资源
- SAGA模式:
- 将大事务拆分为多个本地事务
- 每个本地事务有对应的补偿事务
- 适合长事务场景