init和main函数相关特点
init函数(没有输入参数、返回值)的主要作用:
- 初始化不能采用初始化表达式初始化的变量。
- 程序运行前的注册。
- 实现sync.Once功能。
- 其他
init 执行顺序
不用记那么多,感觉3点就可以概括:
- 1、init 方法可以重复定义,就算同一个go文件也可以,按编写的先后顺序依次执行(不同文件中按文件名先后执行,存疑,应该主要是加载顺序决定)
- 2、对于不同 package,如果不存在依赖按照 main 包中 import 顺序调用;存在依赖则按依赖的顺序倒序初始化,如依赖顺序为: main –> A –> B –> C,则init顺序为 C –> B –> A –> main;
- 3、所有 init 函数都在同⼀个 goroutine 内执行; 所有 init 函数结束后才会执行 main.main 函数;
Go的数据结构的零值是什么?
- 所有整型类型:0
- 浮点类型:0.0
- 布尔类型:false
- 字符串类型:””
指针、interface、切片(slice)、channel、map、function :nil
Go的零值初始是递归的,即数组、结构体等类型的零值初始化就是对其组成元素逐一进行零值初始化。
byte和rune有什么区别
rune和byte在go语言中都是字符类型,且都是别名类型:
- byte型本质上是uint8类型的别名,代表了ASCII 码的一个字符
- rune型本质上是int32型的别名,代表一个 UTF-8 字符
Go struct 能不能比较
需要具体情况具体分析:
如果struct中含有不能被比较的字段类型,就不能被比较。
如果struct中所有的字段类型都支持比较,那么就可以被比较。
不可被比较的类型:
- slice,因为slice是引用类型,除非是和nil比较
- map,和slice同理,如果要比较两个map只能通过循环遍历实现
- 函数类型
其他的类型都可以比较。
还有两点值得注意:
- 结构体之间只能比较它们是否相等,而不能比较它们的大小
- 只有所有属性都相等
且属性顺序都一致
的结构体才能进行比较
变量初始化方式
var a int=10
var a=10
a:=10
var m = make(map[int]int)
Go import的4种方式
// 常规
import (
"fmt"
)
// 点号导入
import (
. "fmt"
)
// 点号导入:在使用相应的包的函数或者变量的时候,可以省略包名,即不带包名前缀调用相应包的函数
Println("Hello World!")
// 别名
import (
f "fmt"
)
// 下划线导入
// 当我们并不真正需要使用这些包,仅希望它里面的init()函数被执行时调用
import (
"database/sql"
_ "github.com/go-sql-driver/mysql"
)
TODO--与其他语言相比,使用 Go 有什么好处?
与其他作为学术实验开始的语⾔不同,Go 代码的设计是务实的。每个功能和语法决策都旨在让程序员的⽣活更轻松。
Golang 针对并发进行了优化,并且在规模上运行良好。
由于单⼀的标准代码格式,Golang 通常被认为比其他语⾔更具可读性。
⾃动垃圾收集GC明显比Java 或 Python 更有效,因为它与程序同时执行。
听说go有什么什么的缺陷,你怎么看
- 1、缺少框架;
- 2、go语言通过函数和预期的调用代码简单地返回错误,容易丢失错误发生的范围;
- 3、go语言的软件包管理没有办法制定特定版本的依赖库。
string和[]byte如何取舍
string(不可修改) 擅长的场景:
- 需要字符串比较的场景;
- 不需要nil字符串的场景;
[]byte擅长的场景:
- 修改字符串的场景(string不可修改),尤其是修改粒度为1个字节;
- 函数返回值,需要用nil表示含义的场景;
- 需要切片操作的场景;
字符串转成byte数组,会发生内存拷贝吗?
会!因为产生了类型强转;
严格来说,只要是发生类型强转都会发生内存拷贝。
频繁的内存拷贝操作听起来对性能不大友好。有没有什么办法可以在字符串转成切片的时候不用发生拷贝呢?参考:https://mp.weixin.qq.com/s/qmlPuGVISx8NYp2b9LrqnA
翻转含有中文、数字、英文字母的字符串
func main() {
src := "你好abc啊哈哈"
sarr := []rune(src)
reverse(sarr)
fmt.Println(string(sarr))
}
func reverse(sarr []rune) {
// 平行赋值?
for i, j := 0, len(sarr)-1; i < j; i, j = i+1, j-1 {
sarr[i], sarr[j] = sarr[j], sarr[i]
}
}
json包变量不加tag会怎么样?
- 转为json后首字母小写(
private
)的不管加不加tag都不能转为json里的内容; - 而大写的(
public
)加了tag可以取别名,不加tag则json内的字段跟结构体字段原名一致。
为什么json包不能导出私有变量的tag?
其实准确说法是因为json包认为私有变量为不可导出的Unexported
,所以跳过了。
部分源码如下:
if isUnexported {
// 如果是不可导出的变量则跳过
continue
}
// 如果是可导出的变量(public),则获取其json字段
tag := sf.Tag.Get("json")
reflect(反射包)如何获取字段tag:
t := reflect.TypeOf(&yourStruct).Elem()
for i := 0; i < t.NumField(); i++ {
fmt.Printf("struct第%v个字段 %v 对应的json-tag是 %v , otherTag(如果有) = %v \n", i+1, t.Field(i).Name, t.Field(i).Tag.Get("json"), t.Field(i).Tag.Get("otherTag"))
}
for-range 中 append元素的同事今天还在么?
func main() {
s := []int{1,2,3,4,5}
for _, v:=range s {
s =append(s, v)
fmt.Printf("len(s)=%v\n",len(s))
}
}
这个代码会造成死循环吗?
不会死循环,for range其实是golang的语法糖,在循环开始前会获取切片的长度 len(切片),然后再执行len(切片)次数的循环。
Printf()、Sprintf()、Fprintf()函数的区别用法是什么
- Printf(),是把格式字符串输出到标准输出(一般是屏幕)。Printf() 是和标准输出文件 (stdout) 关联的,Fprintf 则没有这个限制。
- Sprintf(),是把格式字符串输出到指定字符串中,所以参数比printf多一个char*,就是目标字符串地址。
- Fprintf(),是把格式字符串输出到指定文件设备(stream),所以参数比 printf 多一个文件指针 FILE*, 主要用于文件操作。
Go语言中 cap 函数可以作用于哪些内容?
- array 返回数组的元素个数;
- slice 返回 slice 的最⼤容量;
- channel 返回 channel 的容量;
Golang语言的引用类型有什么?
Go语言中的引用类型有: func(函数类型),interface(接口类型),slice(切片类型),map(字典类型),channel(管道类型),*(指针类型)
另:go没有引用传递,只有值传递,引用类型传递的是指针的值。
for循环select时,如果通道已经关闭会怎么样?如果select中的case只有一个,又会怎么样?
select中如果任意某个通道有值可读时,就会被执行,其他被忽略。
如果没有default字句,select将有可能阻塞,直到某个通道有值可以运行,所以select里最好有一个default,否则将有一直阻塞的风险。
for循环select时:
- 若其中一个case通道已关闭,则每次都会执行到这个case,因为已关闭的通道可被一直读取到其零值;
- 若select只有一个case,且这个case已被关闭,则会出现死循环,原因同上,已关闭的通道可被一直读取到其零值;
若select只有一个case,且检测到case被关闭后将其置为nil,则main协程会被阻塞,陷入deadlock;
// 示例代码 func main() { ch := make(chan int) go func() { time.Sleep(time.Second * 1) ch <- 11 close(ch) }() for { select { // chan 为nil时,执行到这里main会一直阻塞 deadlock case cval, ok := <-ch: fmt.Println("读取到:", cval) time.Sleep(time.Millisecond * 500) if !ok { ch = nil } } fmt.Printf("%v === 随便打印点啥;\n", time.Now().Format("2006-01-02 15:04:05")) } }
Go关键字fallthrough有什么作用
fallthrough关键字只能用在switch中。且只能在每个case分支中最后一行出现,作用是:
如果这个case分支被执行,将会继续执行下一个case分支,而且不会去判断下一个分支的case条件是否成立。
空结构体占不占内存空间? 为什么使用空结构体?
空结构体是没有内存大小的结构体。
通过 unsafe.Sizeof() 可以查看空结构体的宽度:
var s struct{}
fmt.Println(unsafe.Sizeof(s)) // prints 0
准确来说,空结构体有一个特殊起点: zerobase 变量。zerobase是一个占用 8 个字节的uintptr全局变量。每次定义 struct {} 类型的变量,编译器只是把zerobase变量的地址给出去。也就是说空结构体的变量的内存地址都是一样的。
空结构体的使用场景主要有三种:
- 实现方法接收者:在业务场景下,我们需要将方法组合起来,代表其是一个 ”分组“ 的,便于后续拓展和维护。
- 实现集合类型:在 Go 语言的标准库中并没有提供集合(Set)的相关实现,因此一般在代码中我们图方便,会直接用 map 来替代:
type Set map[string]struct{}
。 - 实现空通道:在 Go channel 的使用场景中,常常会遇到通知型 channel,其不需要发送任何数据,只是用于协调 Goroutine 的运行,用于流转各类状态或是控制并发情况。
空结构体的使用场景
空结构体(empty struct)是在 Go 语言中一个特殊的概念,它没有任何字段。在 Go 中,它通常被称为匿名结构体或零宽度结构体。尽管它没有字段,但它在某些情况下仍然有其用途,以下是一些常见的空结构体的使用场景:
占位符:空结构体可以用作占位符,用于表示某个数据结构或数据集合的存在而不实际存储任何数据。这在某些数据结构的实现中非常有用,特别是在要实现某种数据结构的集合或映射时,但并不需要存储实际的值。
// 表示集合中是否包含某个元素的映射,实现集合类型:
set := make(map[string]struct{})
set["apple"] = struct{}{}
信号量:空结构体可以用作信号量,用于控制并发操作。通过向通道发送或接收空结构体,可以实现信号的传递和同步。
// 用通道作为信号量:空通道
semaphore := make(chan struct{}, 5) // 控制并发数为5
go func() {
semaphore <- struct{}{} // 获取信号量
defer func() { <-semaphore }() // 释放信号量
// 执行并发操作
}()
强调结构:有时,空结构体可用于强调某个结构的重要性或存在。它可以用作结构体的标签,表示关注该结构的存在而不是其内容。
// 表示一篇文章的元信息,不包含实际内容
type Article struct {
Title string
Author string
PublishedAt time.Time
Metadata struct{} // 空结构体强调元信息的存在
}
JSON序列化:在处理JSON数据时,有时需要表示一个空对象。可以使用空结构体来表示JSON中的空对象({})。
// 表示一个空的JSON对象
emptyJSON := struct{}{}
jsonBytes, _ := json.Marshal(emptyJSON)
fmt.Println(string(jsonBytes)) // 输出: {}
尽管空结构体没有字段,但它在上述情况下提供了一种轻量级的方式来实现特定的需求,而无需分配额外的内存或定义具体的数据结构。这使得它成为 Go 中的一种有用工具,可以在编写清晰、高效和易于理解的代码时派上用场。
struct的特点
- 用来自定义复杂数据结构
- struct里面可以包含多个字段(属性)
- struct类型可以定义方法,注意和函数的区分
- struct类型是值类型
- struct类型可以嵌套
- GO语言没有class类型,只有struct类型
特殊之处
- 结构体是用户单独定义的类型,不能和其他类型进行强制转换
- golang中的struct没有构造函数,一般可以使用工厂模式来解决这个问题
- 我们可以为struct中的每个字段,写上一个tag。这个tag可以通过反射的机制获取到,最常用的场景就是json序列化和反序列化。
- 结构体中字段可以没有名字,即匿名字段
Go语言中,下面哪个关于指针的说法是错误的?
- 指针不能进行算术运算
- 指针可以比较
- 指针可以是nil
指针不能指向任何类型。。。
针在Go语言中只能指向相同类型的结构体或者基本类型。例如,一个int类型的变量,只能指向int类型的指针。如果尝试将一个不同类型的指针赋给一个变量,将会导致编译错误。
面向对象Demo
type Sayer interface {
Say(msg string)
SayHi()
}
type Animal struct {
Name string
}
type Dog struct {
Animal
}
func SayHi(s Sayer) {
s.Say("hi...")
}
func (a *Animal) SayHi() {
a.Say("hi...")
}
func (a *Animal) Say(msg string) {
fmt.Printf("Animal[%v] say: %v", a.Name, msg)
}
func (d *Dog) Say(msg string) {
fmt.Printf("Dog-%v-say: %v", d.Name, msg)
}
func (d *Dog) SayHi() {
SayHi(d)
}
func main() {
var sayer Sayer
sayer = &Dog{Animal{"Adog"}}
sayer.Say("汪汪。。。")
fmt.Println()
sayer.SayHi()
fmt.Println()
}
Go语言的接口类型是如何实现的?
在Go语言中,接口类型是通过类型嵌入(embedding
)的方式实现的。每个实现了接口的类型的结构体中都有一个隐含的成员,该成员是指向接口类型的指针。通过这种方式,接口实现了对类型的约束和定义。
具体来说,当一个类型实现了某个接口的所有方法后,该类型就被认为是实现了该接口。在结构体中,可以通过嵌入接口类型的方式来实现接口方法。在实现接口方法时,方法的签名需要与接口定义中的方法签名保持一致。
Go 两个接口之间可以存在什么关系?
- 如果两个接口有相同的方法列表,那么他们就是等价的,可以相互赋值;
- 方法列表不同(比如参数类型不同),但方法列表却存在包含关系,则列表多的接口可赋值给少的接口;如A接口包含
f1()
,B接口包含f1(),f2()
两个方法,则B接口可赋值给A接口,因为B接口实现了A接口; - 接口查询是否成功,要在运行期才能够确定。
Demo:
type IAface interface {
afunc() string
}
type IBface interface {
afunc() string
bfunc() int
}
type Stest struct{}
func (s Stest) afunc() string {
return "a"
}
func (s Stest) bfunc() int {
return 1
}
func main() {
var aa IAface = new(Stest)
var bb IBface = Stest{}
fmt.Println(aa.afunc())
aa = bb
// bb = aa // 报错: IAface does not implement IBface (missing method bfunc)
fmt.Println(bb.bfunc())
}
类型断言:如何在运行时检查变量类型?
Go中默认都实现了interface{}(空接口),这意味着,所有的类型如string,int,int64甚至是自定义的struct类型都就此拥有了interface{}的接口;
那么在一个数据通过func funcName(interface{})
的方式传进来的时候,也就意味着这个参数被自动的转为interface{}的类型。
func main() {
a := "1"
assertTest(a)
}
func assertTest(a interface{}) {
v, ok := a.(string)
if !ok {
fmt.Println("当前参数断言str失败!!!")
return
}
fmt.Println("当前参数已被断言为str:", v)
}
类型开关是在运行时检查变量类型的最佳方式。类型开关按类型而不是值来评估变量。
每个 Switch ⾄少包含⼀个case,⽤作条件语句,和⼀个 default,如果没有⼀个 case 为真,则执行。
func TypeAssertion(items ...interface{}) {
for _, x := range items {
switch x.(type) {
case bool:
fmt.Printf("Param #%v is bool", x)
case string:
fmt.Printf("Param #%v is str", x)
case int:
fmt.Printf("Param #%v is int", x)
default:
fmt.Printf("Para` m #%v 's-type is not matched", x)
}
}
}
func main() {
fmt.Println()
xx := 1
TypeAssertion(xx)
fmt.Println()
}
go中常量定义与iota使用
- 第一个常量必须指定一个表达式,后续的常量如果没有表达式,则继承上面的表达式
- 常量不能使用短变量声明模式
- iota会在const关键字出现时被重置为0,即iota仅在const模块内生效,多个const模块之间互不影响
- const块中每新增一行,iota值自增1,即便iota不出现在首行,依然受其影响
数组比较
- 数组可以进行比较,但仅限于相同类型,不同类型会报编译错误;
- 数组长度也是数组属性的一部分,所以长度不同的数组,进行比较,会报错。
如下Demo就会报错:mismatched types [2]int and [3]int
a := [2]int{1, 2}
b := [3]int{1, 2}
fmt.Println(a == b)
Go string的底层实现
源码包src/runTime/string.go.stringStruct定义了string的数据结构(一个struct);
字符串构建过程是根据字符串构建stringStruct,再转化成string:
// 字符串的结构体
Type stringStruct struct{
str unsafe.Pointer // 字符串的首地址
len int // 字符串的长度
}
// 字符串构建:先构建struct再转化成string
func gostringnocopy(str *byte) string{ //根据字符串地址构建string
ss := stringStruct{str:unsafe.Pointer(str),len:findnull(str)} // 先构造 stringStruct
s := *(*string)(unsafe.Pointer(&ss)) //再将stringStruct 转换成string
return s
}
关于 panic 及如何恢复
recover 可以中止 panic 造成的程序崩溃,或者说平息运行时恐慌,recover 函数不需要任何参数,并且会返回一个空接口类型的值。需要注意的是 recover 只能在 defer 中发挥作用,在其他作用域中调用不会发挥作用。
编译器会将 recover 转换成 runtime.gorecover,该函数的实现逻辑是如果当前 goroutine 没有调用 panic,那么该函数会直接返回 nil,当前 goroutine 调用 panic 后,会先调用 runtime.gopaic 函数runtime.gopaic 会从 runtime._defer 结构体中取出程序计数器 pc 和栈指针 sp,再调用 runtime.recovery 函数来恢复程序,runtime.recovery 会根据传入的 pc 和 sp 跳转回 runtime.deferproc,编译器自动生成的代码会发现 runtime.deferproc 的返回值不为 0,这时会调回 runtime.deferreturn 并恢复到正常的执行流程。
总的来说恢复流程就是通过程序计数器来回跳转。
如何避免panic?
首先明确panic定义:go把真正的异常叫做 panic,是指出现重大错误,比如数组越界之类的编程BUG或者是那些需要人工介入才能修复的问题,比如程序启动时加载资源出错等等。
几个容易出现panic的点:
- 函数返回值或参数为指针类型,nil, 未初始化结构体,此时调用容易出现panic,可加 != nil 进行判断
- 数组切片越界
- 如果我们关闭未初始化的通道,重复关闭通道,向已经关闭的通道中发送数据,这三种情况也会引发 panic,导致程序崩溃
- 如果我们直接操作未初始化的映射(map),也会引发 panic,导致程序崩溃
- 另外,操作映射可能会遇到的更为严重的一个问题是,同时对同一个映射并发读写,它会触发 runtime.throw,不像 panic 可以使用 recover 捕获。所以,我们在对同一个映射并发读写时,一定要使用锁,或者使用 sync-Map。
- 如果类型断言使用不当,比如我们不接收布尔值的话,类型断言失败也会引发 panic,导致程序崩溃。
- 如果很多时候不可避免地出现了panic, 记得使用 defer/recover
defer跳坑知多少
坑1、panic会中断其后的所有代码执行,就算你是return
func main() {
fmt.Println(test())
}
func test() error {
var err error
defer func() {
if r := recover(); r != nil {
err = errors.New(fmt.Sprintf("%s", r))
}
}()
// 如果这里直接写:panic("发生了错误"),go会提示你 return 是无效代码:unreachable code;
raisePanic()
return err
}
func raisePanic() {
panic("发生了错误")
}
如上代码输出 nil,为啥捏?因为panic中断了其后的所有代码执行,跳入了defer中,所以 return 语句不会执行,也就是test函数没收到返回值;
怎么解决呢?其实可以用返回值命名的方式:
func test() (err error) { // 返回值命名
// var err error
defer func() {
if r := recover(); r != nil {
// defer匿名函数会按引用的方式给err赋值,所以此处赋值会影响err的结果
// 同时由于采用了有名返回值,无需显式调用 return 即可达到效果
err = errors.New(fmt.Sprintf("%s", r))
}
}()
raisePanic()
return err
}
坑2:recover仅能捕获最后一个panic的内容
func main() {
defer func() {
if err := recover(); err != nil{
fmt.Println(err)
}else {
fmt.Println("fatal")
}
}()
defer func() {
panic("defer panic")
}()
panic("panic")
}
如上输出:defer panic;
panic仅有最后一个可以被revover捕获。
触发panic("panic")后defer顺序出栈执行,第一个被执行的defer中会有panic("defer panic")异常语句,这个异常将会覆盖掉main中的异常panic("panic"),最后这个异常被第二个执行的defer捕获到。
坑3:执行顺序,带有参数的函数被defer调用时会先【顺序】执行计算出参数值
func function(index int, value int) int {
fmt.Println(index)
return index
}
func main() {
defer function(1, function(3, 0))
defer function(2, function(4, 0))
}
如上输出:3421;先【顺序执行】计算两个defer调用时的参数,所以先执行了function(3, 0), function(4, 0)
Go中的指针强转
在 Golang 中无法使用指针类型对指针进行强制转换;但可以借助 unsafe 包中的 unsafe.Pointer 转换。
a := 11
var p *int = &a
// 如下写法会报错:cannot convert p (variable of type *int) to type *float64
// var float64p *float64 = (*float64)(p)
// 但是可通过 unsafe.Pointer 强制转换
var float64p *float64 = (*float64)(unsafe.Pointer(p))
对于指针类型 Pointer 强调以下四种操作:
- 指向任意类型的指针都可以被转化成 Pointer
- Pointer 可以转化成指向任意类型的指针
- uintptr 可以转化成 Pointer
- Pointer 可以转化成 uintptr
uintptr 在 src/builtin/builtin.go 中定义; 其后描述了六种指针转换的情形
其一:*Conversion of a T1 to Pointer to *T2
转换条件:
- T2 的数据类型不大于 T1
- T1、T2 的内存模型相同
因此对于 int 不能强制转换 float64 可以变化为 int -> unsafe.Pointer -> float64 的过程
相等 == 判断的坑
var x interface{}
var y interface{} = []int{3, 5}
fmt.Println(x == x) // true
fmt.Println(x == y) // 输出 false:interface{}比较的是动态类型和动态值,
fmt.Println(y == y) // runtime err: 动态类型是切片,切片不可被比较;
make函数的底层实现
func makeslice(et *_type, len, cap int) unsafe.Pointer {
mem, overflow := math.MulUintptr(et.size, uintptr(cap))
if overflow || mem > maxAlloc || len < 0 || len > cap {
mem, overflow := math.MulUintptr(et.size, uintptr(len))
if overflow || mem > maxAlloc || len < 0 {
panicmakeslicelen()
}
panicmakeslicecap()
}
return mallocgc(mem, et, true)
}
函数功能:
- 前置校验:比如检查切片占用的内存空间是否溢出;
- 调用mallocgc在堆上申请一片连续的内存。
检查内存空间这里是根据切片容量进行计算的,根据当前切片元素的大小与切片容量的乘积得出当前内存空间的大小,检查溢出的条件:
- 内存空间大小溢出了
- 申请的内存空间大于最大可分配的内存
- 传入的len小于0,cap的大小只小于 len
Go 怎么实现func的自定义参数: 将函数类型func作为形参传入另一个func
func add(a, b int) int {
return a + b
}
//定义函数类型 myfunc
type myfunc func(a, b int) int
//形参指定传入参数为函数类型
func ExecMyFunc(cf myfunc, a, b int) int {
return cf(a, b)
}
func main() {
//在go语言中函数名可以看做是函数类型的常量,所以我们可以直接将函数名作为参数传入的函数中。
v := ExecMyFunc(add, 1, 2)
fmt.Println(v)
}
go要求变量申请类型是为了什么?
在 Go 编程语言中,数据类型用于声明函数和变量。
数据类型的出现是为了把数据分成所需内存大小不同的数据,编程的时候需要用大数据的时候才需要申请大内存,就可以充分利用内存。
Go的闭包语法
go语言的闭包可以理解为一个引用外部变量的匿名函数,Go语言中闭包是引用了自由变量的函数,被引用的自由变量和函数一同存在,即使已经离开了自由变量的环境也不会被释放或者删除,在闭包中可以继续使用这个自由变量,因此,简单的说:函数 + 引用环境 = 闭包
;
同一个函数与不同引用环境组合,可以形成不同的实例;
一个函数类型就像结构体一样,可以被实例化;
函数本身不存储任何信息,只有与引用环境结合后形成的闭包才具有“记忆性”,函数是编译期静态的概念,而闭包是运行期动态的概念。
Go语言中int占几个字节?
Go语言中的int的大小是和操作系统位数相关的,如果是32位操作系统,int类型的大小就是4字节; 如果是64位操作系统,int类型的大小就是8个字节
go新手常见错误
- 不能使用短变量声明(Short Variable Declarations)这种方式来设置字段值:
structData.field := 111
会报错; - 关于 nil 的使用
- 不能使用nil初始化一个未指定类型的变量
- nil只能赋值给指针、channel、func、interface、map或slice类型的变量,赋值给其他类型会报错
- 不能直接使用nil值的Slice和Map
- map使用make分配内存时可指定capicity,但是不能对map使用cap函数
字符串不允许使用nil值
// var x = nil //error:不能使用nil初始化一个未指定类型的变量 // var x int = nil //error var x interface{} = nil //OK
数组用于函数传参时是值复制;注意:方法或函数调用时,传入参数都是值复制(跟赋值一致),除非是map、slice、channel、指针类型这些特殊类型是引用传递。
x := [3]int{1, 2, 3} func(arr [3]int) { arr[0] = 7 }(x) fmt.Println(x) // 123 func(arr *[3]int) { (*arr)[0] = 7 }(&x) // 或者之间使用slice传递 fmt.Println(x) // 723
- map中的key不存在时会获取到对应类型的零值,所以判断key是否存在应使用:
if _,ok := myMap["akey"]; !ok {}
- 字符串操作相关
- 不可修改,可转化为
[]byte
再修改;
- 不可修改,可转化为
- 字符串与
[]byte
之间的转换是复制(有内存损耗),可以用map[string][]byte
建立字符串与[]byte之间映射,也可range来避免内存分配来提高性能:for i,v := range []byte(str) {}
- 字符串与
- string的索引操作返回的是byte(或uint8),如想获取字符可使用for range,也可使用unicode/utf8包和exp/utf8string包的At()方法。
- 字符串并不总是UTF8的文本
- len(str)返回的是字符串的字节数,获取字符串的rune数是使用unicode/utf8.RuneCountInString()函数,但注意一些字符也可能是由多个rune组成,如é是两个rune组成。
使用for range迭代String,是以rune来迭代的。一个字符,也可以有多个rune组成。需要处理字符,尽量使用unicode/norm包。for range总是尝试将字符串解析成utf8的文本,对于它无法解析的字节,它会返回oxfffd的rune字符。因此,任何包含非utf8的文本,一定要先将其转换成字符切片([]byte)。
data := "A\xfe\x02\xff\x04" for _,v := range data { fmt.Printf("%#x ",v) } //prints: 0x41 0xfffd 0x2 0xfffd 0x4 (not ok) fmt.Println() for _,v := range []byte(data) { fmt.Printf("%#x ",v) } //prints: 0x41 0xfe 0x2 0xff 0x4 (good)
- 使用for range迭代map时每次迭代的顺序可能不一样,因为map的迭代是随机的。
- switch的case默认匹配规则不同于其它语言的是,匹配case条件后默认退出,除非使用fallthrough继续匹配;而其它语言是默认继续匹配,除非使用break退出匹配。
- 位运算的非操作是^(跟异或位运算一样),有别于其它语言的~。
- 位运算(与、或、异或、取反)优先级高于四则运算(加、减、乘、除、取余),有别于C语言。
- 结构体在序列化时非导出字段(以小写字母开头的字段名)不会被encode,因此在decode时这些非导出字段的值为”0值”
- 程序不等所有goroutine结束就会退出。可通过channel实现主协程(main goroutine)等待所有goroutine完成。
- channel 相关
- 对于无缓存区的channel,写入channel的goroutine会阻塞直到被读取,读取channel的goroutine会阻塞直到有数据写入。
- 从一个closed状态的channel读取数据是安全的,可通过返回状态(第二个返回参数)判断是否关闭;而向一个closed状态的channel写数据会导致panic。
- 向一个nil值(未用make分配空间)的channel发送或读取数据,会导致永远阻塞。
- 在Slice、Array、Map的多行书写最后的逗号不可省略,如果是单行可省;
- 方法接收者是类型(T),接收者只是原对象的值复制,在方法中修改接收者不会修改原始对象的值;如果方法接收者是指针类型(*T),是对原对象的引用,方法中对其修改当然是原对象修改。
- log包中的
log.Fatal和log.Panic不仅仅记录日志,还会中止程序
;它不同于Logging库。
slice 切片专题
nil切片和空切片是否相同(指向的地址一样)?
nil切片和空切片,最大的区别在于指向的数组引用地址是不一样的。
var s1 []int
s2 := make([]int, 0)
s3 := make([]int, 0)
ptr1 := (*reflect.SliceHeader)(unsafe.Pointer(&s1)).Data
ptr2 := (*reflect.SliceHeader)(unsafe.Pointer(&s2)).Data
ptr3 := (*reflect.SliceHeader)(unsafe.Pointer(&s3)).Data
fmt.Printf("s1 ptr:%+v\n", ptr1)
fmt.Printf("s2 ptr:%+v\n", ptr2)
fmt.Printf("s3 ptr:%+v\n", ptr3)
执行如上代码后会发现,显然这俩不是一个玩意儿:
s1 data:0
s2 data:824634379888
s3 data:824634379888
nil切片和空切片指向的地址不一样:
nil切片引用的数组指针地址为0(零),未指向任何实际地址(即现实世界中所认为的零);
空切片引用的数组指针地址非0(非空),并且固定为一个值,所以s2和s3是相等的;
切片的深拷贝与浅拷贝
通过操作符 =
拷贝切片,这是浅拷贝,这种拷贝在底层指向的是同一个数组,相当于给旧变量取了一个别名,堆新变量的修改会影响旧变量的值;
通过函数 copy
拷贝切片,才是我们想要的深拷贝,会将旧切片的值赋值一份给新变量,新旧变量之间互不影响;
可以用如下代码做个测试:
// 由于切片是引用传递,所以可以通过 testSlice 方法修改入参切片的值
func main() {
nums := []int{1, 7, 8}
testSlice(nums)
fmt.Println(nums)
}
func testSlice(nums []int) {
newSlice := []int{9, 5}
// 这种等号赋值,是浅拷贝,只是将 nums 底层指针修改为和 newSlice 一致,并不能修改原始 nums 底层数组的值
// 所以 main 中的输出结果依然是:[1 7 8]
nums = newSlice
}
func testSlice(nums []int) {
newSlice := []int{9, 5}
// 使用 copy 才是深拷贝,会修改 nums 底层数组的值,main 中输出的才是我们想要的:[9 5 8]
copy(nums, newSlice)
}
json库对nil slice
和空slice
的处理是一致的吗?
不一致!!!因为 nil slice 只是声明了slice,却没有给实例化的对象。
type Tt struct {
List []int
}
func main() {
var s1 []int
s2 := make([]int, 0)
j1, _ := json.Marshal(Tt{s1})
j2, _ := json.Marshal(Tt{s2})
fmt.Println(string(j1))
fmt.Println(string(j2))
}
// 如上输出:
{"List":null}
{"List":[]}
nil被json解析为null
,空slice被解析为空数组[]
; 结合json解析再看nil切片和空切片的区别,印象会更加深刻。
拷贝大切片一定比小切片代价大吗?
并不是,因为所有切片的大小都是相同的,参考如下 SliceHeader 源码:
type SliceHeader struct {
Data uintptr
Len int
Cap int
}
SliceHeader 是切片在go的底层实现结构,涉及三个字段(一个uintptr,两个int);
切片中的第一个字段是指向切片底层数组的指针,这是切片的存储空间,第二个字段是切片的长度,第三个字段是容量。
将一个slice变量分配给另一个变量,只是拷贝这三个字段值,所以拷贝大切片跟小切片的代价应该是一样的。
大切片跟小切片的区别无非就是 Len 和 Cap 的值比小切片的这两个值大一些,如果发生拷贝,本质上就是拷贝上面的三个字段。
Golang切片如何删除数据
Go 语言并没有对删除切片元素提供专用的语法或者接口,需要依赖切片本身的特性来删除元素。方法参考如下:
- 1、指定删除位置,如【index := 1】;
- 2、查看删除位置之前的元素和之后的元素;
3、将删除点前后的元素连接起来即可。
示例代码如下:str := []string{"a", "b", "c", "d"} idxDel := 1 // 待删除元素下标;可扩充,删除连续的多个元素 str = append(str[:idxDel], str[idxDel+1:]...) fmt.Println(str)
扩容前后的Slice是否相同?
- 情况一:原数组还有容量可以扩容(实际容量没有填充完),扩容以后的数组还是指向原来的数组, 对一个切片的操作可能影响多个指针指向相同地址的slice。
情况二:原来数组的容量已经达到了最大值,如果再想扩容,go默认会先开一片内存区域,把原来的值拷贝过来,然后再执行append()操作。这种情况丝毫不影响原数组。
func sExt(s1, s2 []int) { s1 = append(s1, 2) // s1扩容后容量已满,原切片的底层数组无法使用,产生了新的底层数组,所以对 s1[0] 的修改不影响原始切片; s1[0] = 1 s2 = append(s2, 2) // s2扩容后容量充足,所以原切片的底层数组无需变更,因此对 s2[0] 修改的同时也修改了原始切片的值; s2[0] = 1 fmt.Println("执行扩容的子函数内,s1: ", s1, " ;s2: ", s2) } func main() { // s := []int{1} s1 := make([]int, 2, 2) s2 := make([]int, 2, 4) sExt(s1, s2) fmt.Println("执行扩容后main函数内,s1: ", s1, " ;s2: ", s2) } // 输出: 执行扩容的子函数内,s1: [1 0 2] ;s2: [1 0 2] 执行扩容后main函数内,s1: [0 0] ;s2: [1 0]
当然,如果要
复制一个slice,最好使用copy函数
。
使用值为 nil 的 slice、map会发生啥
允许对值为 nil 的 slice 添加元素; append 会自动扩容,改变底层数组,所以可以追加;
但对值为 nil 的 map 添加元素,则会造成运行时 panic(获取不存在的key不影响,会取到对应的零值);
所以map使用前要先执行make来分配内存;
slice分配在堆上还是栈上
有可能分配到栈上,也有可能分配到栈上。当开辟切片空间较大时,会逃逸到堆上。
- make([]string, 200) // does not escape
- make([]string, 20000) // escapes to heap
通过命令go build -gcflags "-m -l" xxx.go观察golang是如何进行逃逸分析的;
slice切片截取的坑
x := make([]int, 2, 10)
_ = x[6:10]
_ = x[6:]
_ = x[2:]
- _ = x[6:] 这⼀⾏会发⽣panic, 截取符号 [i:j];
- 如果 j 省略,默认是原切⽚或者数组的⻓度,x 的⻓度是 2,⼩于起始下标 6 ,所以 panic
new一个切片会怎么样
对切片执行new,会导致编译错误;
sp := new([]int)
// new 之后的切片是指针类型`*[]int`,append要求是slice;
// sp = append(sp, 1)
// 正确使用方式如下,当然这其实是脱裤子放屁
*sp = append(*sp, 1)
整型切片(应该就是数组吧)如何初始化?
arr1 := [3]int{1, 2, 3} // 数组常规初始化
arr2 := [...]int{1, 2, 3} // 不指定数组长度,默认会根据赋值推测出一个长度
arr3 := [3]int{0: 3, 2: 4} // 特定下标赋值:[3 0 4]
数组怎么转集合?
func main() {
arr := [5]int{1, 2, 3, 4, 5}
mset := make(map[int]struct{})
for _, v := range arr {
mset[v] = struct{}{}
}
// 测试不存在于集合的值
_, ok := mset[0]
fmt.Println(ok)
}
数组是如何实现根据下标随机访问数组元素的?
例如: a := [10]int{0}
- 计算机给数组a,分配了一组连续的内存空间。
- 比如内存块的首地址为 base_address=1000。
- 当计算给每个内存单元分配一个地址,计算机通过地址来访问数据。当计算机需要访问数组的某个元素的时候,会通过一个寻址公式来计算存储的内存地址。
Map 集合相关
Map源码和实现原理及时空间复杂度
不考虑因删除大量元素导致的空间浪费情况(目前go是留给程序员自己解决),只考虑一个持续增长状态的map空间使用率。
空间复杂度:
最坏情况:由于溢出桶数量超过hash桶数量时会触发缩容,所以最坏情况是数据被集中在一条链上,hash表基本是空的,这时空间浪费O(n)。
最好情况:数据均匀散列在hash表上,没有元素溢出,这时最好的空间复杂度就是扩散因子决定了,当前go的扩散因子由全局变量决定,即loadFactorNum/loadFactorDen = 6.5。即平均每个hash桶被分配到6.5个元素以上时,开始扩容。所以最小的空间浪费是(8-6.5)/8 = 0.1875,即O(0.1875n)
结论:go map的空间复杂度(指除去正常存储元素所需空间之外的空间浪费)是O(0.1875n) ~ O(n)之间。
时间复杂度:
go采用的hash算法应是很成熟的算法,极端情况暂不考虑;所以综合情况下go map的时间复杂度应为O(1)。
实现及原理可参考(如果被问到的话):https://blog.csdn.net/dongjijiaoxiangqu/article/details/109643025
Map的key可以是哪些类型?可以嵌套map吗?
- key 必须是可比较类型,即语言规范中定义的可比较类型:boolean, numeric, string, pointer, channel, interface, 以及仅包含这些类型的struct和array 。
不能作为map key的类型有:slice,map, function。可以嵌套map。
Map怎么知道自己处于竞争状态?是Go编码实现的还是底层硬件实现的?
是Go编码实现的!
在查找、赋值、遍历、删除的过程中都会检测写标志flags,一旦发现写标志置位(等于1),则直接panic。
赋值和删除函数在检测完标志是复位状态(等于0)之后,先将写标志位置位,才会进行之后的操作。
Map的panic能被recover掉吗?了解panic和recover的机制?
不能被 recover!
map 不是线程安全的,所以并发读写执行时候会报
fatal error: concurrent map read and map write
,从而使程序直接退出。func main() { defer mapErrHandler() m := map[string]int{} go func() { for { m["map"] = 1 } }() for { _ = m["map"] } } func mapErrHandler() { // 此处 map的panic,无法被 recover // 程序会直接抛出:fatal error: concurrent map read and map write if r := recover(); r != nil { fmt.Println(r) } } // ======= 相关源码 func mapaccess1_faststr(t *maptype, h *hmap, ky string) unsafe.Pointer { ... if h.flags&hashWriting != 0 { throw("concurrent map read and map write") } ... }
Go中两个map对象如何比较
使用
reflect.DeepEqual
这个函数进行比较。- 使用 reflect.DeepEqual 有一点注意:由于使用了反射,所以有性能的损失。如果你多做一些测试,那么你会发现 reflect.DeepEqual 会比 == 慢 100 倍以上。
如果一个map没申请空间(nil),此时写入值或读取值,会发生什么情况?
- 写入会
panic: assignment to entry in nil map
; - 读取正常,但是取到的是相应value类型的零值;
map的优缺点以及改进?
优点:
- map类似其他语言中的哈希表或字典,以key-value形式存储数据
- key支持==或!=比较运算的类型,不可以是函数、map或slice
- map通过key查找value比线性搜索快很多。
- map使用make()创建,支持:=这种简写方式
- 超出容量时会自动扩容,
- 当键值对不存在时自动添加,使用delete()删除某键值对
缺点:
- map不是并发安全的
并发安全的sync.Map
如果对map的并发有要求,可选用开箱即用的 sync-map;
func main() {
var sm sync.Map
for i := 0; i < 33; i++ {
sm.Store("k"+strconv.Itoa(i), i)
}
knum := 0
sm.Range(func(key, value any) bool {
fmt.Println(key, ": ", value)
knum++
// 由于map是无序的,所以每次执行返回的结果都不一样
if knum > 6 {
// 返回false终止遍历
return false
}
// 返回true继续执行遍历
return true
})
}
Channel 专题
Channel的大小是否对性能有影响
Channel的大小对性能会产生一定的影响。Channel的大小是指Channel可以容纳的元素数量,可以通过在创建Channel时指定容量大小来控制。
- 当Channel的容量较小时,可能会导致发送和接收操作的阻塞,从而影响程序的性能。
- 而当Channel的容量较大时,可能会增加系统的内存开销,也可能会导致Channel中的元素被占用的时间较长,从而影响程序的响应性。
Channel的内存模型是什么
在Go语言中,Channel的内存模型是基于通信顺序进程(Communicating Sequential Processes,CSP)模型的。CSP模型是一种并发计算模型,它将并发程序看作是一组顺序进程,这些进程通过Channel进行通信和同步。
在CSP模型中,每个进程都是独立的,它们之间通过Channel进行通信。Channel是一个具有FIFO特性的数据结构,用于在多个进程之间传递数据。当一个进程向Channel发送数据时,它会阻塞等待,直到另一个进程从Channel中接收到数据。同样地,当一个进程从Channel中接收数据时,它也会阻塞等待,直到另一个进程向Channel发送数据。
在Go语言中,Channel的内存模型采用了CSP模型的概念,即每个Channel都是一个独立的顺序进程。当一个进程向Channel发送数据时,数据会被复制到Channel的缓冲区或者直接发送到接收方。当一个进程从Channel中接收数据时,数据会被从Channel的缓冲区中取出或者等待发送方发送数据。
Channel的读写操作是否是原子性的,如何实现
Channel的读写操作是原子性的,并且是由Go语言内部的同步机制来保证的。
当一个goroutine进行Channel的读写操作时,Go语言内部会自动进行同步,保证该操作的原子性和顺序性。这种同步机制主要涉及到两个部分:
- 基于锁的同步:在Channel的底层实现中,使用了一种基于锁的同步机制,它可以保证每个读写操作都是原子性的,避免了多个goroutine同时读写导致的数据竞争问题。
- 基于等待的同步:当一个goroutine进行Channel的读写操作时,如果Channel当前为空或已满,它就会被添加到等待队列中,直到满足条件后才会被唤醒,这种等待的同步机制可以避免因Channel状态不满足条件而导致的死锁问题。
通过这种基于锁和等待的同步机制,Go语言保证了Channel的读写操作是原子性的,可以在多个goroutine之间安全地进行通信和同步。
如何避免在Channel中出现死锁的情况
- 避免在单个goroutine中对Channel进行读写操作:如果一个goroutine同时进行Channel的读写操作,很容易出现死锁的情况,因为该goroutine无法切换到其他任务,导致无法释放Channel的读写锁。因此,在进行Channel的读写操作时,应该尽量将它们分配到不同的goroutine中,以便能够及时切换任务。
- 使用缓冲Channel:缓冲Channel可以在一定程度上缓解读写操作的同步问题,避免因为Channel状态不满足条件而导致的死锁问题。如果Channel是非缓冲的,那么写操作必须等到读操作执行之后才能完成,反之亦然,这种同步会导致程序无法继续执行。而如果使用缓冲Channel,就可以避免这种同步问题,即使读写操作之间存在时间差,也不会导致死锁。
- 使用select语句:select语句可以在多个Channel之间进行选择操作,避免因为某个Channel状态不满足条件而导致的死锁问题。在使用select语句时,应该注意判断每个Channel的状态,避免出现同时等待多个Channel的情况,这可能导致死锁。
使用超时机制:在进行Channel的读写操作时,可以设置一个超时时间,避免因为Channel状态不满足条件而一直等待的情况。如果超过一定时间仍然无法读写Channel,就可以选择放弃或者进行其他操作,以避免死锁。
Channel在go中起什么作用
在 Go 中,channel 是一种用于在 goroutine 之间传递数据的并发原语。channel 可以让 goroutine 在发送和接收操作之间同步,从而避免了竞态条件,从而更加安全地共享内存。
channel 类似于一个队列,数据可以从一个 goroutine 中发送到 channel,然后从另一个 goroutine 中接收。channel 可以是有缓冲的,这意味着可以在 channel 中存储一定数量的值,而不仅仅是一个。如果 channel 是无缓冲的,则发送和接收操作将会同步阻塞,直到有 goroutine 准备好接收或发送数据。
Channel为什么需要两个队列实现
一个Channel可以被看作是一个通信通道,用于在不同的进程之间传递数据。在具体的实现中,一个Channel通常需要使用两个队列来实现。这两个队列是发送队列和接收队列。
发送队列是用来存储将要发送的数据的队列。当一个进程想要通过Channel发送数据时,它会将数据添加到发送队列中。发送队列中的数据会按照先进先出的顺序被逐个发送到接收进程。如果发送队列已经满了,那么发送进程就需要等待,直到有足够的空间可以存储数据。
接收队列是用来存储接收进程已经准备好接收的数据的队列。当一个进程从Channel中接收数据时,它会从接收队列中取出数据。如果接收队列是空的,那么接收进程就需要等待,直到有新的数据可以接收。
使用两个队列实现Channel的主要原因是为了实现异步通信。发送进程可以在发送数据之后立即继续执行其他任务,而不需要等待接收进程确认收到数据。同样,接收进程也可以在等待数据到达的同时执行其他任务。这种异步通信的实现方式可以提高系统的吞吐量和响应速度。
Go为什么要开发Channel,而别的语言为什么没有
在Go语言中,Channel是一种非常重要的并发原语。Go语言将Channel作为语言内置的原语,可能是出于以下几个方面的考虑:
并发安全:在多线程并发环境下,使用Channel可以保证数据的安全性,避免多个线程同时访问共享数据导致的数据竞争和锁的开销。
简单易用:Go语言中的Channel是一种高度抽象的概念,可以非常方便地实现不同线程之间的数据传输和同步。通过Channel,程序员不需要手动地管理锁、条件变量等底层的同步原语,使得程序的编写更加简单和高效。
天然支持并发:Go语言中的Channel与goroutine密切相关,这使得Channel天然地支持并发。程序员可以通过使用Channel和goroutine来实现非常高效的并发编程。
虽然其他编程语言中没有像Go语言中的Channel这样的内置并发原语,但是许多编程语言提供了类似于Channel的实现,比如Java的ConcurrentLinkedQueue、Python的Queue、C++的std::queue等。这些实现虽然没有Go语言中的Channel那么简单易用和高效,但也能够满足多线程编程中的数据传输和同步需求。
Channel底层是使用锁控制并发的,为什么不直接使用锁
虽然在Go语言中,Channel底层实现是使用锁控制并发的,但是Channel和锁的使用场景是不同的,具有不同的优势和适用性。
首先,Channel比锁更加高级和抽象。Channel可以实现多个goroutine之间的同步和数据传递,不需要程序员显式地使用锁来进行线程间的协调。Channel可以避免常见的同步问题,比如死锁、饥饿等问题。
其次,Channel在语言层面提供了一种更高效的并发模型。在使用锁进行并发控制时,需要程序员自己手动管理锁的获取和释放,这增加了代码复杂度和错误的风险。而使用Channel时,可以通过goroutine的调度和Channel的阻塞机制来实现更加高效和简单的并发控制。
此外,Channel还可以避免一些由锁导致的性能问题,如锁竞争、锁粒度过大或过小等问题。Channel提供了一种更加精细的控制机制,能够更好地平衡不同goroutine之间的并发性能。
总的来说,虽然Channel底层是使用锁控制并发的,但是Channel在语言层面提供了更加高级、抽象和高效的并发模型,可以使程序员更加方便和安全地进行并发编程。
Channel可以在多个goroutine之间传递什么类型的数据
在Go语言中,Channel可以在多个goroutine之间传递任何类型的数据,包括基本数据类型、复合数据类型、结构体、自定义类型等。这些数据类型在传递过程中都会被封装成对应的指针类型,并由Channel进行传递。
如何在Channel中传递复杂的数据类型
在Go语言中,Channel可以传递任何类型的数据,包括复杂的数据类型。如果要在Channel中传递复杂的数据类型,可以将其定义为一个结构体,然后通过Channel进行传递。
chan控制:select
select 是 Go 中的一个控制结构,类似于 switch 语句。
- select 语句只能用于通道操作,每个 case 必须是一个通道操作,要么是发送要么是接收。
- select 语句会监听所有指定的通道上的操作,一旦其中一个通道准备好就会执行相应的代码块。
- 如果多个通道都准备好,那么 select 语句会随机选择一个通道执行。如果所有通道都没有准备好,那么执行 default 块中的代码。
select 注意事项:
- 每个 case 都必须是一个通道
- 所有 channel 表达式都会被求值
- 所有被发送的表达式都会被求值
- 如果任意某个通道可以进行,它就执行,其他被忽略。
- 如果有多个 case 都可以运行,select 会随机公平地选出一个执行,其他不会执行。
- 否则:
- 如果有 default 子句,则执行该语句。
- 如果没有 default 子句,select 将阻塞,直到某个通道可以运行;Go 不会重新对 channel 或值进行求值。
示例:用select判断channel是否处于closed状态
func chanIsClosed(c chan int) bool {
select {
case _, ok := <-c:
// chan 状态正常(true),返回false,代表通道未关闭
return !ok
default:
// 如果被判断的chan尚未就绪,select会一直阻塞,所以一定要添加 default 子句
}
return false
}
func main() {
cp := make(chan int)
go func() {
time.Sleep(time.Second * 1)
cp <- 1
close(cp)
}()
fmt.Println("chan is closed? ", chanIsClosed(cp))
fmt.Println("received from chan: ", <-cp)
fmt.Println("chan is closed? ", chanIsClosed(cp))
}
定义有缓冲的chan,可在同一个协程中操作chan;
同一协程中,若chan未设置发送,但select中存在接收,会deadlock;
// 有缓冲的chan可在同一个协程中操作
func main() {
ch := make(chan int, 1)
ch <- 99
select {
case v := <-ch:
fmt.Println("ch: ", v)
}
}
// 如下会deadlock
func main() {
// 不论有没有缓冲,都会deadlock
ch := make(chan int)
select {
case v := <-ch:
fmt.Println("ch: ", v)
}
// 有缓冲的chan可以单独设置发送,不会报错;但若只设置接收,会deadlock
}
for...select 跳出方法:
func main() {
tick := time.Tick(time.Second)
BREAKTAG:
for {
select {
case v := <-tick:
fmt.Println("tick: ", v)
break BREAKTAG // 方法1:定义前置标签,直接break到for循环
// goto END // 方法2:定义后置标签,goto到后置标签
}
}
// END:
fmt.Println("end...")
}
select实现超时控制机制
func main() {
ch := make(chan int)
quit := make(chan struct{})
go func() {
for {
select {
case v := <-ch:
fmt.Println("received:", v)
case <-time.After(time.Second * 3):
fmt.Println("等待超时...")
quit <- struct{}{}
}
}
}()
for i := 0; i < 5; i++ {
ch <- i
time.Sleep(time.Second) // 阻塞1s便于观察,小于超时时间3s
}
<-quit // 阻塞以保证子协程执行
fmt.Println("执行完成...quit")
close(quit)
close(ch)
}
用Go撸一个生产者消费者模型,用channel通信,如何友好的关闭chan?
优雅的关闭 chan 需牢记两点:
- 向一个已关闭的channel发送数据会panic
- 关闭一个已经关闭的channel会panic
所谓友好的关闭,其实就是尽可能不触发panic,所有上面两点是问题的key所在。
简单起见,如下代码使用空select实现阻塞。select{}
语句内为空,不包含case和default代码时,将会永久阻塞。
针对单个生产者的情况 在发送侧关闭channel即可
单个生产者单个消费者模式:
func main() {
var ch = make(chan int)
// 单生产者
go func() {
for i := 1; i < 100; i++ {
ch <- i
}
close(ch)
}()
// 消费者
go func() {
for elem := range ch {
fmt.Println(elem)
}
}()
select {}
}
单个生产者多个消费者模式
func main() {
var ch = make(chan int)
// 单生产者
go func() {
for i := 1; i < 100; i++ {
ch <- i
}
close(ch)
}()
// 多消费者
for i := 0; i < 100; i++ {
go func() {
for elem := range ch {
fmt.Println(elem)
}
}()
}
select {}
}
无论是单消费者还是多消费者,只要是单个生产者的场景,都可以在生产者一侧直接关闭chan,因为向已关闭的chan读取数据不会产生panic,只会取到对应的零值,消费者只需做个if判断即可。
多生产者模式:比较麻烦,不能直接关闭chan,需要借助一个中间chan信号
多生产者模式,使用的是同一个chan,如果其中一个chan发起关闭,会导致其他未退出的协程向已关闭的chan发送数据,引发panic,所以chan不能关闭。
多生产者单消费者模式:
func main() {
var ch = make(chan int)
var stopCh = make(chan struct{}) // 退出信号
for i := 1; i <= 30; i++ {
go func(n int) {
for {
// 这里借助select实现协程的退出
select {
case ch <- n:
case <-stopCh:
fmt.Println("接收到停止信号")
return
}
}
}(i)
}
// 单消费者
go func() {
for elem := range ch {
fmt.Println(elem)
// 此处的判断条件只是一个终止信号(实际中要根据业务需求做调整),因为协程执行的顺序随机,所以获取到上面的循环中最后一个值,不代表所有chan的值都遍历完成了。
if elem == 30 {
// 关闭代表 退出信号 的chan,如果不关闭,多生产者只有一个会接收到,其余协程会阻塞
// stopCh 仅是一个退出信号,即便被关闭依然可以取到零值,但无需判断值,所以不影响
close(stopCh)
return
}
}
}()
select {}
}
多生产者多消费者模式:
func main() {
var ch = make(chan int)
var stopCh = make(chan struct{}) // 退出信号
var toStopCh = make(chan struct{}) // 协调信号
for i := 1; i <= 30; i++ {
go func(n int) {
for {
// 这里借助select实现协程的退出
select {
case ch <- n:
case <-stopCh:
fmt.Println("接收到停止信号")
return
}
}
}(i)
}
// 协调信号量
go func() {
<-toStopCh
close(stopCh)
}()
// 多消费者
for i := 1; i <= 30; i++ {
go func() {
for v := range ch {
fmt.Println(v)
if v == 30 {
toStopCh <- struct{}{}
return
}
}
}()
}
time.Sleep(time.Second)
}
Goroutine 协程相关
如果是无缓冲的chan,则main函数中对chan的操作需放到协程代码逻辑之后,如果放到之前,会在协程执行前就陷入deadlock,而非阻塞。
协程执行chan的读取或写入,都可以正常执行,两种模式代码如下,都可以正常执行:
// 协程执行chan的读取
chSignal := make(chan int)
// chSignal <- 999 // 如果对chan的操作放到协程逻辑之前,会直接陷入 deadlock
go func() {
t := <-chSignal
close(chSignal)
fmt.Println("接收到信号值:", t)
}()
chSignal <- 999 // main函数中对chan的操作需放到协程代码逻辑之后
// 协程执行chan的写入
chSignal := make(chan int)
go func() {
chSignal <- 999
}()
t := <-chSignal // main函数中对chan的操作需放到协程代码逻辑之后
close(chSignal)
fmt.Println("接收到===信号值:", t)
开N个协程,全部执行一个函数,怎么保证所有协程都执行完了,并打印出结果
func main() {
wg := sync.WaitGroup{}
routineNum := 5
wg.Add(routineNum)
for i := 0; i < routineNum; i++ {
go func(n int) {
defer wg.Done()
fmt.Println(n+1, "号协程执行打印完成")
}(i)
}
wg.Wait()
fmt.Println("All协程done")
}
用Channel和两个协程实现数组相加
func add(a, b []int) []int {
if len(a) > len(b) {
return add(b, a)
}
ch := make(chan int)
ans := make([]int, len(b))
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for _, v := range a {
ch <- v
}
}()
go func() {
for k, v := range b {
ans[k] = <-ch + v
}
}()
wg.Wait()
return ans
}
func main() {
a := []int{2, 4, 6, 8}
b := []int{1, 3, 5, 7}
fmt.Println("数组-a: ", a)
fmt.Println("数组-b: ", b)
fmt.Println("加和后: ", add(a, b))
}
2个协程交替打印字母和数字
func main() {
n := 26
var wg sync.WaitGroup
wg.Add(2)
chChar, chNum := make(chan struct{}), make(chan struct{})
go func() {
defer wg.Done()
for i := 0; i < n; i++ {
<-chNum
fmt.Println(i + 1)
chChar <- struct{}{}
}
<-chNum // 无缓冲ch需消费被冗余写入的信号,避免deadlock
}()
go func() {
defer wg.Done()
for i := 0; i < n; i++ {
<-chChar
fmt.Printf("%c\n", i+'a')
time.Sleep(time.Millisecond * 200)
chNum <- struct{}{} // 此处会冗余写入一次
}
}()
chNum <- struct{}{} // 对首发ch写入启动信号
wg.Wait()
}
go的 子routine 发生panic,主进程会panic吗
如果主Goroutine中没有捕获recover的逻辑,那么整个进程就会panic从而挂掉。
func main() {
fmt.Println("子routine发生panic,主进程会同时panic吗")
defer errCatch() // 不添加recover捕获会导致主进程挂掉
subfun()
fmt.Println("看样子不会呢...")
}
func subfun() {
panic("会哦...")
}
func errCatch() {
if r := recover(); r != nil {
fmt.Println(r)
}
}
协程中直接使用当前函数的变量,和传参的区别是什么,为什么会造成这种结果
- 直接使用:
会造成所有子携程都只能取到变量i终止遍历时的值
wg := sync.WaitGroup{} for i := 0; i < 10; i++ { wg.Add(1) go func() { fmt.Println(i) wg.Done() }() } wg.Wait() // 输出:10 10...10(全部都是10)
- 传参或使用临时变量
会输出我们想要的值
// 方法1: wg := sync.WaitGroup{} for i := 0; i < 10; i++ { wg.Add(1) tmp := i go func() { fmt.Print(tmp, " ") wg.Done() }() } wg.Wait() // 方法2: fmt.Println() // wg := sync.WaitGroup{} for i := 0; i < 10; i++ { wg.Add(1) go func(newI int) { fmt.Print(newI, " ") wg.Done() }(i) } wg.Wait() // 以上两种方案皆OK,输出:9 5 6 7 8 1 0 2 3 4 (顺序随机)
产生这种结果的原因是,对于一个协程来说,函数在执行异步执行注册时,该函数并未真正开始执行注册,只是在goroutine的栈中保存了变量i的内存地址,而一旦开始执行函数时才会去读取变量i的值,而这时变量i的值已经自增到了10,改进的方案就是在注册异步执行函数的时候,把变量的值也一并传递获取,或者吧当前变量i的值赋值给一个不会改变的临时变量中,在函数中使用该临时变量而不是直接使用当前函数的变量 i。
协程实现交替执行123的顺序打印
func main() {
done := make(chan struct{})
ch1, ch2, ch3 := make(chan int), make(chan int), make(chan int)
cnt := 3
nums := []int{1, 2, 3}
chArr := []chan int{ch1, ch2, ch3}
startId := 0
print123 := func(grId int, chOut, chIn chan int) {
for i := 0; i < cnt; i++ {
n := <-chOut
fmt.Println("GR-", grId, ":", n)
chIn <- nums[n%len(nums)] // trick:取模方式自动将 下标id+1
}
if grId == startId {
<-chOut // 最后一个协程会冗余执行一次 start-ch 的写入
done <- struct{}{} // 写入done信号
}
}
for idx, n := range nums {
go print123(idx, chArr[idx], chArr[n%len(nums)]) // trick: 取模
}
chArr[startId] <- nums[startId] // 对 start-ch 执行写入,以启动流程
// go print123(0, ch1, ch2)
// go print123(1, ch2, ch3)
// go print123(2, ch3, ch1)
// ch1 <- nums[0]
<-done
fmt.Println("All done...")
}
两个协程交替打印1到20
- 定义两个chan,比如A和B,A协程先写入B的chan后输出A自己的chan,B协程则相反,先输出B的chan值后写入A的chan
定义waitGroup,以保证协程有足够时间开启
func main() { ch1 := make(chan int) ch2 := make(chan int) wg := &sync.WaitGroup{} wg.Add(2) go say1(wg, ch1, ch2) go say2(wg, ch1, ch2) wg.Wait() } func say1(wg *sync.WaitGroup, ch1, ch2 chan int) { defer wg.Done() defer close(ch2) for i := 1; i <= 10; i++ { fmt.Println(<-ch1) ch2 <- 2 * i } } func say2(wg *sync.WaitGroup, ch1, ch2 chan int) { defer wg.Done() defer close(ch1) for i := 1; i <= 10; i++ { ch1 <- 2*i - 1 fmt.Println(<-ch2) } } // 版本2: func main2(){ var wg sync.WaitGroup ch := make(chan int) wg.Add(2) go func() { defer wg.Done() for i := 2; i <= 20; i = i + 2 { fmt.Println("GR-1:", <-ch) ch <- i } }() go func() { defer wg.Done() for i := 1; i <= 20; i = i + 2 { ch <- i fmt.Println("GR-2:", <-ch) time.Sleep(time.Second) } }() wg.Wait() close(ch) }
一个线程打印奇数一个线程打印偶数 交替打印
func main() {
ch := make(chan struct{})
wg := sync.WaitGroup{}
wg.Add(2)
go func() {
defer wg.Done()
for i := 1; i <= 20; i++ {
<-ch
if i%2 == 0 {
fmt.Println("even: ", i)
}
}
}()
go func() {
defer wg.Done()
defer close(ch)
for i := 1; i <= 20; i++ {
ch <- struct{}{}
if i%2 == 1 {
fmt.Println("odd: ", i)
}
}
}()
wg.Wait()
}
两个协程交替打印一个数组,使数组中的数据按顺序输出
func main() {
var wg sync.WaitGroup
datas := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
dataChan := make(chan int) // 数据chan
ctrlChan := make(chan struct{}) // 信号控制
wg.Add(3)
go func() {
defer wg.Done()
for _, v := range datas {
dataChan <- v
}
}()
go func() {
defer wg.Done()
for i := 0; i < len(datas); i++ {
if i%2 == 0 {
fmt.Println("gr-1: ", <-dataChan)
}
ctrlChan <- struct{}{} //偶数下标仅执行信号写入
}
}()
go func() {
defer wg.Done()
for i := 0; i < len(datas); i++ {
if i%2 == 1 {
fmt.Println("gr-2: ", <-dataChan)
}
<-ctrlChan //奇数下标仅执行信号输出
}
}()
wg.Wait()
}
WaitGroup相关
WaitGroup 是常见的一种并发控制方式,它可以让我们的代码等待一组 goroutine(协程) 的结束,比如在主协程中等待几个子协程去做一些耗时操作,如发起几个 HTTP 请求,然后等待它们的结果。
WaitGroup 内部通过一个计数器来统计有多少协程被等待。这个计数器的值在我们启动 goroutine 之前先写入(.Add(n)), 然后在 goroutine 结束的时候,将这个计数器减 1(.Done());最后调用 Wait() 来进行等待(阻塞),在 Wait 调用的地方程序会阻塞,直到 WaitGroup 内部的计数器减到 0 为止。从而实现了等待一组 goroutine 执行完成的目的。
WaitGroup 源码中有一个字段 noCopy,顾名思义,它的目的是防止复制。因为一旦允许复制,WaitGroup 内的计数器就不再准确了,所以需要传递的场景,需要使用指针。
如下调用会导致 fatal error: all goroutines are asleep - deadlock!
// go 里面的函数参数传递是值传递。调用 test(wg) 的时候将 WaitGroup 复制了一份,会导致计数器一直无法清零,从而一直阻塞
func main() {
wg := sync.WaitGroup{}
wg.Add(1)
test(wg)
wg.Wait()
}
func test(wg sync.WaitGroup) {
defer wg.Done()
fmt.Println("wg 执行完毕")
}
如果需要传递waitGroup,需要改用如下的指针方式:
func main() {
wg := &sync.WaitGroup{}
wg.Add(1)
test(wg)
wg.Wait()
}
func test(wg *sync.WaitGroup) {
defer wg.Done()
fmt.Println("wg 执行完毕")
}
参考:https://juejin.cn/post/7181812988461252667
;
其实用 chan 阻塞去实现等待也能实现这个效果,不过没有 WaitGroup 用着方便,初入坑的话,可以先用 chan 实验几次,再使用 WaitGroup,便于理解其内在逻辑和使用方式。
WaitGroup 有哪些坑?
- 【不要】Add一个负数:如果计数器的值小于0会直接panic;
- 【不要】复制waitgroup:WaitGroup有nocopy字段,该字段并非指出现拷贝逻辑会报语法错误,而是为了让 go vet 工具可以检测到被复制了,这也意味着WaitGroup不能作为函数的参数;
- Add要在子协程开启前调用:如果在子协程内部开启Add,会导致Wait无法阻塞子协程;
- 计数器未置为0就重用:wait可以重复使用,但再次使用前尽量保证计数器清零,虽然不清零不影响使用,但会导致出现问题时很难排查;
Golang的协程通信有哪些方式
1)共享内存
- 共享内存是指多个协程直接访问共享变量的方式,这种方式不需要显式地进行通信,但需要考虑并发访问时的竞态问题,需要使用互斥锁等机制来确保同步和一致性。
2)通道
- 通道是Go语言中一个重要的并发原语,它是一种线程安全的、带缓冲的FIFO队列。通道支持阻塞式读写,可以用来在不同的协程之间传递数据,也可以用来进行同步操作。通道在多个协程之间传递数据时,会自动进行同步,不需要程序员显式地进行加锁和解锁操作。
3)选择器
- 选择器是Go语言中的一种控制结构,可以同时监听多个通道的操作,并选择其中一个可以进行操作的通道。选择器可以用来实现非阻塞的通信操作,避免了因等待某个通道操作而导致的阻塞。选择器通常与通道配合使用,用于多个协程之间的协作和同步。
4)条件变量(Cond)
- 条件变量用于在协程之间进行复杂的通信和协调。在 Go 中,可以使用sync包中的Cond类型来实现条件变量。它通常与互斥锁一起使用,以便协程可以在特定条件下等待或被唤醒。
5)原子操作(Atomic Operations)
- Go 语言提供了sync/atomic包,用于执行原子操作,这些操作通常用于共享资源的更新,以避免竞态条件。原子操作可以用于对变量的读取、写入、加法等操作,而不需要额外的锁定。
总之,Go协程之间的通信是非常重要的,不同的应用场景需要选择不同的通信方式,以确保程序的正确性和性能。共享内存通常用于需要高性能的并发场景,但需要注意线程安全和同步问题;通道是一种简单、安全、高效的通信方式,适用于大多数并发场景;选择器则适用于多通道协作和同步的场景。
context 相关
context 这玩意儿是干啥的呢,也是处理并发的;WaitGroup 虽然实现了阻塞并等待协程执行的功能,但是并发不是只有阻塞等待,最重要的是,管理层不能白拿钱呀,boss给了指示,你得传递下去还得接收处理结果并向上反馈呀。
context可以在各个goroutine之间传递上下文信息,这个上下文的概念比较宽泛,可以指代比如 特定的一组数据、信号量、抑或是本次请求的处理截止时间
等;并且同一个context可以传递给运行在不同goroutine中的函数。
比如下面的逻辑,就可以使用context的 WithCancel 实现:
func main() {
// chan + select 自实现模式
stop := make(chan bool)
go func() {
for {
select {
case <-stop:
fmt.Println("监控退出,停止了...")
return
default:
fmt.Println("goroutine 监控中...")
time.Sleep(time.Second)
}
}
}()
time.Sleep(5 * time.Second)
fmt.Println("到点儿下班了,通知监控停止")
stop <- true
// 如果没有监控输出,就表示停止了
time.Sleep(2 * time.Second)
// context-WithCancel 模式
ctx, cancel := context.WithCancel(context.Background())
go func(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("监控退出: ", ctx.Err())
return
default:
fmt.Println("goroutine 监控中...")
time.Sleep(time.Second)
}
}
}(ctx)
time.Sleep(5 * time.Second)
fmt.Println("到点儿下班了,通知监控停止")
cancel()
// 如果没有监控输出,就表示停止了
time.Sleep(2 * time.Second)
}
值得注意的是这个 cancel
方法:
cancel
是如何实现将取消信号通知到所有子协程的呢,反查源代码可以发现这里包含一个小trick
,源码注释中就说了,cancel closes c.done
,也就是这个取消操作会将信号通道关闭;
而chan
的特性在于,向一个已关闭的chan
获取数据不会报错,如果数据已取完,就返回对应类型的零值,正是利用这一特性来实现对所有子协程的通知。
也因此,cancel
使用时需要多加注意,由于它不会主动阻塞正在运行的逻辑,所以需要我们自己主动添加阻塞逻辑,参考如下示例:
协程-1
的执行需要9秒
,而我们在5秒
时就通知协程执行取消操作了,但这并不会阻止协程-1
的继续执行,因此我们需要尽可能的将c.Done()
逻辑添加到程序执行的最小单位中,以避免损失。
func main() {
var wg sync.WaitGroup
ctx, cancel := context.WithCancel(context.Background())
wg.Add(2)
go func(sctx context.Context) {
defer wg.Done()
for {
select {
case <-sctx.Done():
fmt.Println("协程1号退出太晚不符合预期。。。")
return
default:
for i := 0; i < 9; i++ {
fmt.Println("GR-1: ", i+1)
time.Sleep(time.Second)
}
}
}
}(ctx)
go func(sctx context.Context) {
defer wg.Done()
for i := 0; i < 9; i++ {
select {
case <-sctx.Done():
fmt.Println("协程2号按预期退出")
return
default:
fmt.Println("GR-2: ", i+1)
time.Sleep(time.Second)
}
}
}(ctx)
time.Sleep(time.Second * 5)
fmt.Println("开始执行取消操作:调用 Cancel")
cancel()
wg.Wait()
}
上下文对于多个goroutine同时使用是安全的(这点很重要,也就是一旦用起来出了啥问题,不用自己背锅)。
context包定义了上下文类型,可以使用background、TODO创建一个上下文,在函数调用链之间传播context,也可以使用 WithDeadline、WithTimeout、WithCancel 或 WithValue 创建的修改副本替换它,这么多方法看的头晕,大白话就是:领导递给你一个包裹,作为管理层的你,加一层包装贴个标签,传递给下面的小弟去执行就OK,拿到结果后再拆开包装撕了标签,改成领导想要的结果就行。
看起来挺像网购流程的,卖家把物品交给快递公司,快递公司裹上自家的外包装并贴上物流单号去运输,最后快递小哥送到你手上,你再给卖家一个反馈说货物收到了,还可以拒收,修改收货地址,退货啥的。
目前golang一些常用库都支持了context,例如gin、sql等,如此一来只要在服务入口创建一个context上下文,不断透传下去即可。
WithCancel: 主动通知子协程取消执行
func main() {
// 初始化context,并定义采用 WithCancel
ctx, cancel := context.WithCancel(context.Background())
// 调用协程并传入ctx
go SpeakGo(ctx)
// 设定取消子协程执行的时间间隔
time.Sleep(time.Second * 6)
// 取消子协程的执行
cancel()
// sleep 1s 以观察 context 的 Err 信息
time.Sleep(time.Second)
fmt.Println("done...")
}
func SpeakGo(ctx context.Context) {
// 设定每秒输出一次; time.Tick 会返回一个通道,所以可以 for range 遍历
for range time.Tick(time.Second) {
select {
// 捕获ctx停止信号
case <-ctx.Done():
fmt.Println("闭嘴:", ctx.Err())
// 终止执行
return
default:
fmt.Println("say you say me...")
}
}
}
WithTimeout: 主动通知子协程取消执行
func main() {
HttpHandler()
}
func HttpHandler() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
defer cancel()
deal(ctx)
}
func deal(ctx context.Context) {
for i := 0; i < 6; i++ {
time.Sleep(time.Second)
select {
case <-ctx.Done():
fmt.Println("主协程取消执行:", ctx.Err())
return
default:
fmt.Println("deal with me...")
}
}
}
WithValue: 全局日志打印Demo
// 为logKey单独定义type,以防止不同包之间重名
type loggerkey string
const (
LOGKEY loggerkey = "traceid"
)
func GetTraceId() string {
uuidStr := strings.Replace(uuid.New().String(), "-", "", -1)
return uuidStr
}
func GenerateLogStr(ctx context.Context, msg string) string {
logid, ok := ctx.Value(LOGKEY).(string)
if !ok {
logid = "logidErr"
}
return fmt.Sprintf("%s|traceid=%s|%s", time.Now().Format("2006-01-02 15:04:05"), logid, msg)
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
ctx = context.WithValue(ctx, LOGKEY, GetTraceId())
wg := &sync.WaitGroup{}
wg.Add(2)
go ShowLog(ctx, 1, wg)
go ShowLog(ctx, 2, wg)
time.Sleep(time.Second * 5) // 5s 后取消打印
cancel()
wg.Wait()
}
func ShowLog(ctx context.Context, gid int, wg *sync.WaitGroup) {
for range time.Tick(time.Second) {
select {
case <-ctx.Done():
fmt.Println("任务取消:", ctx.Err())
wg.Done()
return
default:
fmt.Println("GR-", gid, ": ", GenerateLogStr(ctx, "日志测试"))
}
}
}
Context 使用原则
- 不要把Context放在结构体中,要以参数的方式传递
- 以Context作为参数的函数方法,应该把Context作为第一个参数,放在第一位。
- 给一个函数方法传递Context的时候,不要传递nil,如果不知道传递什么,就使用context.TODO
- Context的Value相关方法应该传递必须的数据,不要什么数据都使用这个传递
- Context是线程安全的,可以放心的在多个goroutine中传递
go 如何实现OOP面向对象
- 封装:package概念,同一包内变量可见,不同包,首字母大写可见;
- 继承:结构体内直接嵌入另一结构体,即可实现类似继承的功能;
- 多态:
- 重写:方法重写(override覆盖),继承即可;
- 重载:明确不支持,用【可变参数(params ...interface{})】可变相实现;
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。