package test
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
_ "net/http/pprof"
"os"
"runtime"
"strings"
"sync"
"syscall"
"testing"
"time"
"unsafe"
)
func TestSliceOp(t *testing.T) {
//a := [3]int{} 数组1
a := [...]int{1, 2, 3, 4, 5, 6, 7, 8} //数组2
//不管是数组还是切片,使用[index:index]这种方式来截取时都是引用原地址的,即b c改变元素,相应的a的元素也会改变
b := a[0:4]
c := a[3:6]
for i := 0; i < len(b); i++ {
t.Log(b[i])
}
for i := 0; i < len(c); i++ {
t.Log(c[i])
}
c[0] = 999
t.Log(a[3], b[3])
//申明一个切片,3为len,即index为0,1,2都int的零值;8为cap,为2的指数幂
ab := make([]int, 3, 8) //切片1
//ab := []int{1, 2, 3} 切片2
//ab := []int{} 切片3
//此时下表为3的值为1,而不是0;因为切片是可扩容的,相当于重新找一个之前二倍大小的连续空间,所以append必须赋值给原数组
ab = append(ab, 1)
//对于切片中,只申请len,没有申请cap时,则cap = len;下面这个切片,初识长度为3且容量也没3;当继续append元素时则cap x 2变为6;
//但是cap是建议为2的幂次方的。
aaa := make([]int, 3)
t.Log(len(aaa), cap(aaa))
//slice通过append赋值
aaa = append(aaa, 1)
t.Log(len(aaa), cap(aaa))
}
func TestSlice(t *testing.T) {
a := []int{}
a = append(a, 1)
for i, v := range a {
//a切片下标为0,值为1
fmt.Println(fmt.Sprintf("a切片下标为%v,值为%v", i, v))
}
//创建一个len和cap都为3的切片,len为3之内的元素都是int零值
b := make([]int, 3)
b = append(b, 1)
//注意:切片也可以使用下标赋值,,但有严格的前提条件:
//1.切片必须已经通过make函数创建
//2.下标不能超出当前长度范围
//append:动态添加元素,可能触发扩容,返回新切片
//下标赋值:修改已有位置的元素值,不改变切片长度和容量
b[1] = 3 //当前长度为3,所以b[1]可以赋值
for i, v := range b {
//b切片下标为0,值为0
//b切片下标为1,值为3
//b切片下标为2,值为0
//b切片下标为3,值为1 //特别注意:60行的append并不是赋值下标为1,而下标为4;也就是说append赋值的下标是len(b)+1
fmt.Println(fmt.Sprintf("b切片下标为%v,值为%v", i, v))
}
c := make([]int, 1, 3)
c = append(c, 1)
//panic: runtime error: index out of range [3] with length 2
//c[3] = 1
for i, v := range c {
//c切片下标为0,值为0,len为2,cap为3
//c切片下标为1,值为1,len为2,cap为3
fmt.Println(fmt.Sprintf("c切片下标为%v,值为%v,len为%v,cap为%v", i, v, len(c), cap(c)))
}
c[1] = 5 // 可以看到这里的值覆盖了74行的1
for i, v := range c {
//c切片下标为0,值为0,len为2,cap为3
//c切片下标为1,值为5,len为2,cap为3
fmt.Println(fmt.Sprintf("c切片下标为%v,值为%v,len为%v,cap为%v", i, v, len(c), cap(c)))
}
}
func TestMapOp(t *testing.T) {
a := map[string]int{}
a["one"] = 1
a["two"] = 2
b := make(map[string]int, 2)
b["one"] = 1
b["two"] = 2
//判断map的key是否存在
if _, ok := b["three"]; ok {
t.Log("存在")
} else {
t.Log("不存在")
}
// 数组,切片,map,通道都可以使用range来遍历
for key, value := range a {
t.Log(key, value)
}
//4为map的cap而不是len,当容量超过4之后就会扩容;为什么map不可以设置len,因为初始化len时都是零值,而map的key是唯一的,所以不能有len
//map的cap决定了性能,如果能提前知道容量,则最好设置cap
// 注意:map是引用类型
c := make(map[string]int, 4)
a["one"] = 1
c["two"] = 2
t.Log(len(c))
//map的value可以是一个匿名方法,特别注意当方法的返回值为1个时是可以没有括号的,如果返回值超过1个则必须有括号(go中的方法是可以返回多值的)
//go中的函数可以当成一个type使用就更好理解了
aa := map[string]func(op int) (res int, double int){} //只是定义了一个匿名函数,只定义方法的入参数和出参数,并不需要定义匿名函数的实现
aa["one"] = func(op int) (res int, double int) { return res, res * res } //这里实现了匿名的函数
f := map[string]func(op int) int{} //只是定义了一个匿名函数,只定义方法的入参数和出参数,并不需要定义匿名函数的实现
f["one"] = func(op int) int { return op } //这里实现了匿名的函数
f["two"] = func(op int) int { return op * op * op } //这里实现了匿名的函数
t.Log(f["one"](2), f["two"](3))
}
// 全局变量只能通过var来申明,var的方式初始值为nil,需要实例化之后才可以使用;或者直接全局变量初始化setA = map[string]int{}这种方式申明
// map的零值为nil,所以必须在方法内实例化
var setA map[string]bool
// 可以看到方法定义返回值时,key为result,类型为bool,这种方式叫命名返回值,相当于var result bool,那么在方法中则可以直接使用result变量,且不需要重新申明了,
// 同时return时不需要显示指定result了;若返回参数值大于1个时则需要使用()
func setHashSet(key string) (result bool) {
if _, ok := setA[key]; !ok {
setA[key] = true
result = true
} else {
result = false
}
return
}
func getSetList() (result string) {
parts := []string{}
for key, val := range setA {
//%s普通字符串;%d十进制整数int;%f 默认小数形式;%.2f保留俩位小数;%t布尔值;%p输出指针
//如果不知道类型,则%v是一个通用格式说明符,用于以默认格式输出任何类型的值
parts = append(parts, fmt.Sprintf("%v:%v", key, val))
}
//将数组按照,拼接为字符串
result = strings.Join(parts, ",")
return
}
func TestSetA(t *testing.T) {
//注意:全局变量一定要初始化
setA = map[string]bool{}
a := setHashSet("aa")
t.Log(fmt.Sprintf("%v", a))
b := setHashSet("bb")
t.Log(fmt.Sprintf("%v", b))
c := setHashSet("bb")
t.Log(fmt.Sprintf("%v", c))
dd := getSetList()
t.Log(dd)
}
// ------------------函数----------------------
// go中的函数完全也是一种数据类型,完全可以把它当成一中type,类型int string
// 全局变量只能通过var的方式申请,且需要在函数中实例化,不然报nil指针
var kk func(n int) int //申明变量时,只定义函数体,即入参和出参
var hh []func(n int) //申明变量时,只定义函数体,即入参和出参
// sum的零值为0,所以可以不用在方法内实例化
var sum int
func TestFunc1(t *testing.T) {
//为什么不能写成这样呢 append(hh, getSum(2))?因为这里存储的是函数引用而非函数调用结果,实际上append(hh, getSum(2))函数会立即执行并返回
//所以函数本身就是一个引用
hh = append(hh, getSum) //实现变量时需要实现结构体
hh = append(hh, getSum)
hh = append(hh, getSum)
for i := 0; i < 3; i++ {
hh[i](i + 3)
}
t.Log(fmt.Sprintf("sum值为%v", sum))
}
func getSum(num int) {
sum += num
}
var suma int
var a []int
var op []string
func TestFunc2(t *testing.T) {
a = []int{100, 2, -10, -50}
op = []string{"+", "-", "*", "/"}
for i, _ := range a {
funDefineNum(funcTODO, op[i], i)
}
t.Log(fmt.Sprintf("last num is %v", suma))
}
func funDefineNum(a func(op string, index int), operation string, num int) {
a(operation, num)
}
func funcTODO(op string, index int) {
switch op {
case "+":
suma += a[index]
case "-":
suma -= a[index]
case "*":
suma *= a[index]
case "/":
suma /= a[index]
}
}
// ---------可变参数和defer--------------
// 1. 通过...可不限制参数数量 2. 所有参数类型都是一致 3. 所有参数是以切片的形式展示
// defer和php中的finally的作用是一样的,是在方法内的代码最后执行,尽管抛出异常同样也是会执行的
// defer可以用调用函数,也可以调用一个闭包
func TestArgs(t *testing.T) {
//defer会调用一个闭包
defer func(sum int) {
t.Log(fmt.Sprintf("defer执行总和为%v", sum))
//recover可以捕获panic的异常;err则为panic抛出的异常
if err := recover(); err != nil {
stack := make([]byte, 1024)
//获取异常栈
length := runtime.Stack(stack, false)
t.Log(fmt.Printf("异常: %v\n堆栈:\n%s", err, stack[:length]))
}
}(sum)
sum := intSum(1, 2, 3, 4, 5, 6, 7, 8)
t.Log(sum)
panic("抛出一个错误异常")
}
func intSum(num ...int) (sum int) {
//num可以像普通切片一样使用,len(num) ,num[i]
for _, num := range num {
sum += num
}
return
}
// 当入参或者出参的类型完全一致时,可以通过下面这种方式来定义,只定义最后一个参数的类型即可
// 1. 入参name,motherName,fatherName的类型都是any,即interface{}类型
// 2. 出参old,high,weight的类型都是int
func sameParams(name, motherName, fatherName any) (old, high, weight int) {
return 1, 1, 1
}
// args是一个任何类型的”切片“
func Info(code int, msgFormat string, args ...interface{}) {
//args... 是 Go 语言中的可变参数展开语法,... 是展开操作符,将切片中的元素展开为独立的参数
fmt.Sprintf(msgFormat, args...)
}
// fmt.Sprintf()格式化字符串,返回值为格式化后的字符串,但不可换行
// fmt.Printf()格式化字符串且将格式化字符串标准输出到终端,但不可换行,返回值是写入字节数
// fmt.Println() 不能格式化字符串,只能一串字符串标准输出到终端,且支持换行,返回值为nil
// 1. 如果需要将格式化的字符串输出到终端且支持换行:1. fmt.Println(fmt.Sprintf("年龄为%v",12)) 2. fmt.Printf(fmt.Sprintf("年龄为%v\n",12))
// 2. 如果只需要将一串字符串输出到终端:fmt.Println("我的年龄为12")
// ---------------------struct定义------------------
// todo 看下form 和 json
// strut作为参数传递时是值传递,如果为引用传递则需要加*
type employee struct {
name string
age int
}
// 这里需要注意:struct的属性首字母必须是大写的才可以使用form json这些,小写的只有在当前包才可以使用
type Employee1 struct {
Name string `form:"name" json:"name" binding:"required"`
Age int `form:"age" json:"age" binding:"required"`
}
// employee类型是值传递
// 可以看到employee的地址和e(new(employee))的地址不一样,也就是该方法中的employee又copy了实例化的对象,存在内存复制,会浪费性能
// employee.name的逻辑是familyName + 原employee.name,也就是这里的employee.name只会作用于该方法内的copy的employee对象,而不会作用于e(new(employee))或者其他employee对象
func (employee employee) getName(familyName string) {
employee.name = familyName + employee.name
//getName方法的employee地址为0x1400043c528
fmt.Println(fmt.Sprintf("getName方法的employee地址为%v\n", unsafe.Pointer(&employee)))
}
// *employee是引用传递
// 可以看到employee的地址和e(new(employee))的地址是一模一一样,也就是该方法中的employee对象作用于e或者所有*employee
// 没有存在内存复制,也就意味着没有浪费性能,类中的方法建议都使用*employee这种方式
func (employee *employee) getAge(addAge int) {
employee.age = addAge + employee.age
//getAge方法的employee地址为0x1400043c510
fmt.Println(fmt.Sprintf("getAge方法的employee地址为%v\n", unsafe.Pointer(employee)))
}
// 实例化struct的几种方式
func TestStudyStruct(t *testing.T) {
//指定key: value的方式,注意这种方式返回的不是引用地址
//a := employee{name: "哈哈", age: 12}
//a.getName("刘")
//a.getAge(5)
//t.Log(fmt.Sprintf("%s的年龄为%v;employee的地址为%v", a.name, a.age, unsafe.Pointer(&a)))
////哈哈的年龄为17
//t.Log(fmt.Sprintf("%s的年龄为%v", a.name, a.age))
//顺序对struct内的属性进行赋值,这种方式必须对所有属性都进行赋值,注意这种方式返回的也不是引用地址
//b := employee{"哈哈", 12}
//下面这俩中方式相对于上面的俩种方式,唯一的不同点是,返回的是引用地址
//c := &employee{name: "哈哈", age: 12}
//d := &employee{"哈哈", 12}
//这种方式也是返回引用地址,和c d对象是完全一致的,只是写法不同
e := new(employee)
e.name = "哈哈"
//e.age = 12
e.getAge(3)
e.getName("续")
////哈哈的年龄为15;employee的地址为0x1400043c510
t.Log(fmt.Sprintf("%s的年龄为%v;employee的地址为%v", e.name, e.age, unsafe.Pointer(e)))
//通过json直接赋值给employee struct
caseJson := `{"name":"小雨aaa","age":18}`
//字符串底层就是byte数组, []byte(caseJson) 是将字符串转化为byte数组;string(aaa)是将byte数组转化为字符串
aaa := []byte(caseJson)
rt := new(Employee1)
//Unmarshal将caseJson赋值给rt struct
err := json.Unmarshal(aaa, rt)
fmt.Println(fmt.Sprintf("rt对象:%v", rt))
if err == nil {
//将aaa byte数组转化为字符串
fmt.Println(rt.Name, string(aaa))
}
}
func TestJson(t *testing.T) {
jsonStr := `{"name":"我是你爸爸","age":56}`
// 1. json字符串转换为对象(定义对象结构体)
person := &Employee1{}
// 2. 一个参数是一个byt数组需要使用[]byte(),第二个参数是一个引用地址;将jsonStr转化为person对象
err := json.Unmarshal([]byte(jsonStr), person)
if err == nil {
fmt.Println(fmt.Sprintf("姓名:%v,年龄:%v", person.Name, person.Age))
}
// 3. json对象转化为一个字符串
lastStr, err := json.Marshal(person)
if err == nil {
fmt.Println(fmt.Sprintf("字节数组为:%v,json字符串为%v", lastStr, string(lastStr)))
}
//------------上面这种json的转化是常用的,但是需要提前定一个一个结构体-------------------------
//如果并不知道json字符串的结构体怎么办呢?通常定义一个interface的map,来实现
strMap := make(map[string]interface{})
err = json.Unmarshal([]byte(jsonStr), &strMap)
if err != nil {
return
}
if value, ok := strMap["name"]; ok {
fmt.Println(fmt.Sprintf("您的名字为:%v", value))
}
if value, ok := strMap["age"]; ok {
fmt.Println(fmt.Sprintf("您的年龄为:%v", value))
}
}
// 泛型:是不用明确指定具体的类型,可以在实际执行时在确定具体的类型
func TestFanxing(t *testing.T) {
fmt.Println(fmt.Sprintf("%v,%v,%v", fanxing("o"), fanxing(1), fx(43434)))
}
func fanxing(n interface{}) string {
//获取泛型的类型
switch n.(type) {
case string:
return "字符串"
case int:
return "整形"
case bool:
return "bool"
}
return ""
}
// any和interface{}是一个作用。但是需要注意和any和interface{}是引用类型
func fx(n any) string {
// 断言
if _, ok := n.(string); ok {
return "整形"
}
if _, ok := n.(int); ok {
return "int"
}
return "无类型"
}
// -----------------------------------interface接口---------------------------------------------------
// 需要注意:interface是一个引用地址
type school interface {
// 获取全校人数以及各个班级学生数
getStudentNum() (nums int, numByClass map[string]int)
getClassNum(className string) (num int)
}
type middleSchool struct {
//特别注意:在结构体内申请变量不能使用var,只能使用下面这种方式申请
classNameNum map[string]int
}
func (middleSchool *middleSchool) getStudentNum() (nums int, numByClass map[string]int) {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
}
}()
if middleSchool.classNameNum == nil {
panic("setA 未初始化")
}
for _, num := range middleSchool.classNameNum {
nums += num
}
numByClass = middleSchool.classNameNum
return
}
func (middleSchool *middleSchool) getClassNum(className string) (num int) {
defer func() {
if err := recover(); err != nil {
fmt.Println(err)
}
}()
if middleSchool.classNameNum == nil {
panic("setA 未初始化")
}
if nums, ok := middleSchool.classNameNum[className]; ok {
num = nums
}
return
}
// 入参是一个school的interface,interface是一个指针类型
func hah(bb school) {
}
func TestInterface(t *testing.T) {
//实例化类
middleSchool := &middleSchool{}
//类中的属性也是需要实例化,零值为nil
aa := make(map[string]int)
aa["1年纪"] = 20
aa["2年纪"] = 10
aa["3年纪"] = 30
middleSchool.classNameNum = aa
num := middleSchool.getClassNum("2年纪")
fmt.Println(fmt.Sprintf("2年纪的人数为%v", num))
sum, _ := middleSchool.getStudentNum()
fmt.Println(fmt.Sprintf("所有年纪的总人数为%v", sum))
//由于middleSchool是引用类型,所以没问题,如果middleSchool := middleSchool{},则hah的入参则会报错
hah(middleSchool)
}
// ------------------匿名结构体嵌套-------
type company struct {
}
// 部门数
func (company *company) departments() int {
return 20
}
// 员工数
func (company *company) employees() int {
return 200
}
// duxiaoman 结构体中匿名嵌套了一个company结构体,这种方式叫做匿名嵌套
// 需要注意的,struct name的首字母为小些时代表包外不可访问,如果duxiaoman 和 company不在同一个包则不能嵌套
type duxiaoman struct {
company
//匿名嵌套了middleSchool struct,由于middleSchool实现了school interface,所以duxiaoman 也实现school interface
//如果被嵌入的结构体实现了某个接口的方法,那么嵌入它的结构体也会隐式地实现该接口,这被称为“方法提升”
middleSchool
}
// duxiaoman struct重写departments方式
func (duxiaoman *duxiaoman) departments() int {
return 50
}
func TestExtend(t *testing.T) {
a := &duxiaoman{}
//部门数为:50,员工数为:200
// 1. duxiaoman struct会继承company的所有方式和成员
// 2. duxiaoman stuct支持重写嵌套的方法和成员
fmt.Println(fmt.Sprintf("部门数为:%v,员工数为:%v", a.departments(), a.employees()))
//部门数为:20,员工数为:200
// 如果想访问“父类”的方法,可以直接像下面通过访问成员变量的方式访问
fmt.Println(fmt.Sprintf("部门数为:%v,员工数为:%v", a.company.departments(), a.company.employees()))
}
// ------------go中的错误机制-------------
// 1. 没有try catch 机制
// 2. 只能通过方法的多返回值特性,一层一层的返回error
// 3. 可以发现error是一个接口,有一个方法Error()返回string类型,错误信息通过该方法Error()返回
// 4. affa的返回值需要返回一个实现error接口的struct
func affa() (int, error) {
//1. error是一个接口,所以errors.New方法内可以看到return &errorString{text},返回的是一个引用类型
//2. errorString实现了error接口,定义了一个属性s string(s即为错误信息)和实现了 error接口的Error(),该接口内返回错误信息
return 0, errors.New("报错了")
}
//-------------包管理mod---------------------
//go.mod文件结构
// module:声明模块路径
// go:指定Go语言版本
// require:列出依赖项及其版本
// replace:替换依赖包路径或版本
// exclude:排除特定依赖版本
//go常用命令
// go mod init:创建go.mod文件
// go mod download:下载go.mod文件中指定的所有依赖
// go mod tidy:添加缺失的依赖并删除未使用的依赖,确保go.mod与代码中的导入匹配
// go mod vendor:创建vendor目录副本
// go mod verify:验证依赖项的哈希值是否与go.sum记录一致
//与传统GOPATH的区别
// 与GOPATH相比,go mod允许代码开发不局限于特定目录,依赖管理更加自动化,无需手动管理每个包。使用go mod后,项目可以放在任意目录,不再需要严格的GOPATH/src结构
// 通过go mod管理依赖,可以确保不同机器上构建环境的一致性,大大简化了Go项目的依赖管理流程
// -------------------------------协程 start----------------------------------
func TestXiecheng(t *testing.T) {
for i := 0; i < 10; i++ {
//协程:通过go关键字加一个闭包实现
go func(i int) {
//打印出来的是1-10的乱序,因为每个协程是异步的,且执行完成时间不同
fmt.Println(fmt.Sprintf("first:%v", i))
//这里将i变量传递入协程中,i类型为int,则为值传递,所以打印出来的是1-10
}(i)
}
for i := 0; i < 10; i++ {
go func() {
//打印出来的全是10
//由于并没有将i变量传入闭包,所以所有的协程都共享了i变量。又由于协程的启动是异步的,不保证立即执行,而循环执行非常快,可能在所有协程启动前就已经结束
//当协程实际执行时,i已经递增到结束值10
fmt.Println(fmt.Sprintf("sencod:%v", i))
}()
time.Sleep(10)
}
//为什么这里要使用sleep。因为可能TestXiecheng方法执行完成后,10个协程还没有执行完,这种情况就造成了内存泄漏。下面又更有的当时group.done()来实现
time.Sleep(10)
}
var n int
// 锁机制 Mutex是互斥锁,核心作用是确保同一时间只有一个 goroutine 能访问临界区代码,防止数据竞争和竞态条件。也就是主要是作用于goroutine内资源竞争的
//
// 在同一 goroutine 中不可重复加锁,否则会引发运行时异常(panic)。
// 未加锁Lock则解锁Unlock,也会导致panic。
// 该锁是线程锁(进程锁,它主要用于保护同一进程内的共享资源,它只能协调单个 Go 进程内多个 goroutine 对共享资源的访问。而微服务通常部署在多台机器上,
// 每个实例都有自己的进程,sync.Mutex 无法在这些独立的进程或机器之间提供同步控制。如果是需要多个进程间对资源进行加锁,则需要使用分布式锁,例如redis锁
// 下面这个例子比较简单,可能不符合业务场景,可以继续看下面的updateMap1方法,可以深刻体会下sync.Mutex的使用方法
func TestSyncLock(t *testing.T) {
// 对于Mutex和WaitGroup通常引用变量,因为本身就是协调协程之间资源的,肯定要是引用地址
lock := &sync.Mutex{}
// waitGroup可以让主线程优雅等待所有子协程执行完成后在推出。
// 1. group.Add(1) 表示添加一个子协程认为
// 2. group.Done() 表示该子协程任务执行完成
// 3. group.Wait() 主线程等待所有子协程done,才可以继续执行,否则主线程这里hold等待阻塞
// 4. sync.WaitGroup一定要使用指针类型&sync.WaitGroup,因为是作用多个协程之间的
// 总结:waitGroup是为了主线程在子协程都执行完成后继而再退出,避免主线程提前退出而子协程仍然在执行中,这种情况就造成了协程都内存泄漏
group := &sync.WaitGroup{}
for i := 0; i < 100; i++ {
group.Add(1)
go lockInfo(group, lock)
}
group.Wait()
// 正常输出100
fmt.Println(fmt.Sprintf("n最终执行为:%v", n))
}
// 特别注意:WaitGroup和Mutex必须是引用类型的,因为是作用于多个协程之间资源的
func lockInfo(group *sync.WaitGroup, mutex *sync.Mutex) {
defer func() {
group.Done()
//注意:如果在加锁之前报错,导致没有加锁成功,这里解锁则会直接报错
mutex.Unlock()
}()
mutex.Lock()
//特别注意:为什么这里要对n加锁?
// 因为:n是全局变量,多协程之间是共享的,所以要必须对公共资源加锁
n++
}
// --------------------在介绍另外一种锁读写锁-sync.RWMutex---------------------------
// sync.RWMutex是读写锁,而sync.mutex是互斥锁,RWMutex相对于mutex锁的粒度更小,性能更好点,但不是所有场景都适合使用读写锁,还是分场景的。
// sync.Mutex:当一个goroutine获得锁后,其他goroutine无论进行读操作还是写操作都必须等待锁释放,这种锁适用于需要严格保证数据一致性的场景,但并发性能相对较低
// sync.mutex的主要方法是
// 1. mutex.lock 加锁,多goroutine之间只有一个goroutine可以获取成功,其他goroutine获取锁时则阻塞
// 2. mutex.unlock 解锁,对加锁的goroutine进行解锁,解锁成功之后,其他goroutine则可以重新获取到锁
//
// sync.RWMutex:实现了读写分离机制,它允许多个goroutine同时获取读锁进行读操作,但写锁仍然是互斥的,具体表现为
// 1. 读锁之间不互斥,多个goroutine可同时获得读锁
// 2. 写锁之间完全互斥,同一时间只能有一个goroutine持有写锁
// 3. 写锁与读锁互斥,存在读锁时写锁阻塞,存在写锁时读锁阻塞
//
// sync.RWMutex的主要方法
// 1. RLock:加读锁,多goroutine可同时获取到读锁
// 2. RUnlock:解读锁,对加读锁的goroutine进行解锁
// 3. Lock:加写锁,多goroutine之间只有一个goroutine可以获取写锁成功,其他goroutine获取锁时则阻塞
// 4. Unlock:解写锁,对加写锁的goroutine进行解锁,解锁成功之后,其他goroutine则可以重新获取到写锁
//
// sync.Mutex和sync.RWMutex适用的场景也不同
// 1. sync.Mutex适用于严格保护共享数据,确保同一时刻“只有一个goroutine访问的临界区”,类似于库存超卖,以及下面TestSyncMap方法,可以体会下,适用sync.Mutex的场景通常有几个特点
// 1>. “同一个方法内”同时存在对一个对象的“读写操作”,而“写操作依赖读操作的数据属性”,同时“写操作的数据属性也会影响读操作的数据”
// 2>. “读操作开始前需要加锁”,“写操作之后需要解锁”
// 3>. 当多goroutine访问该方法时,如果A goroutine处于刚读完读操作,还未到写操作时,此时另一个B goroutine也开始读取读操作了,那么B goroutine此时读操作读取的数据就是脏数据了,
// 因为A goroutine还没有执行完写操作,那么此时使用sync.Mutex进行加锁则非常合适了
// 2. sync.RWMutex适用于“读写分离”的场景,且读多写少的场景,最重要的是“读写操作没有依赖的场景”,通常也有几个特点
// 1>. “读写分离”的场景怎么理解:可以理解对一个对象有单独的读场景 和 单独的写场景,或是有单独的俩个方法分别是读或者写
// 2>. 读和写场景之间没有完全依赖,是解耦的
// 3>. 最适合缓存系统、配置系统
//
// 如果说了这么多还是不懂,则看下面的例子吧,这是一个配置管理系统,有一个全局的配置对象,且有读配置和写配置操作,对于配置文件系统通常需要有下面加个特点
// 1. readConfig读配置:程序中频繁读取配置,加读锁RLock
// 2. writeConfig写配置:管理员偶尔更新配置,加写锁Lock
// 3. 当读取配置时需要判断此时是否正在更新配置;
// 1>. 如果是则读操作阻塞,只有等更新操作更新完成才能读取,否则将会读取到没有更新完成的配置数据,就造成了数据不一致;
// 2>. 如果否则可以放肆读取配置了,多goroutine之间读配置不加锁
// 4. 当更新配置时判断此时是否正在更新配置
// 1>. 是:只有等更新操作更新完成才能再次更新,否则将会更新到还没有更新完成的配置数据,就造成了数据错乱
// 2>. 否:那么此时仍需要判断是否正在读取配置
// 1>. 是:只有等读取操作完成之后才能更新,否则立刻更新时,读取数据则有可能读到更新的数据,也造成了数据不一致的
// 2>. 否:当更新配置时,即没有正在更新和正在读取配置的操作是,才可以立即更新配置,否则只能阻塞
//
// 可能有人问了,如果读配置不加任何锁,而写配置时加互斥锁或者写锁,会发生什么情况?
//
// 在配置管理系统中,如果读配置操作不加任何锁,而写配置操作使用互斥锁或写锁,会导致严重的数据一致性问题,具体表现为:当读操作不加锁时,多个goroutine可以
// 同时读取配置数据,而写操作在修改数据时虽然会加锁保证独占性,但无法阻止其他goroutine在写操作执行过程中读取到中间状态的数据,例如,在写操作更新DatabaseURL
// 和CacheSize两个字段的过程中,读操作可能读取到部分更新的配置——获取到新的DatabaseURL但仍然是旧的CacheSize值,最终造成了数据错乱,数据不一致的场景
type config struct {
databaseUrl string
caseSize int
debugMode bool
}
var configRWMutexLock = &sync.RWMutex{}
// 比如是一个加载到内存的变量
var currentConfig = &config{"127.0.0.1:8801", 10, true}
func readConfig(group *sync.WaitGroup, i int) (conf *config) {
defer func() {
configRWMutexLock.RUnlock()
group.Done()
fmt.Println(fmt.Sprintf("读取配置结束%v", i))
}()
configRWMutexLock.RLock()
fmt.Println(fmt.Sprintf("读取配置开始%v", i))
time.Sleep(time.Millisecond * 100)
conf = currentConfig
return
}
func writeConfig(group *sync.WaitGroup, i int) {
defer func() {
configRWMutexLock.Unlock()
group.Done()
fmt.Println(fmt.Sprintf("写入配置结束%v", i))
}()
configRWMutexLock.Lock()
fmt.Println(fmt.Sprintf("写入配置开始%v", i))
time.Sleep(time.Millisecond * 100)
//更新配置
currentConfig.caseSize = 12
}
func TestReadConfig(t *testing.T) {
group := &sync.WaitGroup{}
for i := 0; i < 3; i++ {
group.Add(1)
go readConfig(group, i)
}
group.Wait()
fmt.Println("全部读取流程DONE")
//读取配置开始2
//读取配置开始0
//读取配置开始1
//读取配置结束1
//读取配置结束2
//读取配置结束0
//全部读取流程DONE
//说明可以同时获取多个读锁的,读读不互斥
}
func TestWriteConfig(t *testing.T) {
group := &sync.WaitGroup{}
for i := 0; i < 3; i++ {
group.Add(1)
go writeConfig(group, i)
}
group.Wait()
fmt.Println("全部读写流程DONE")
//写入配置开始2
//写入配置结束2
//写入配置开始0
//写入配置结束0
//写入配置开始1
//写入配置结束1
//全部读写流程DONE
//可以看出只有当前协程释放写锁之后新的协程才可以获取写锁,说明写锁是互斥锁
}
func TestRWConfig(t *testing.T) {
group := &sync.WaitGroup{}
for i := 0; i < 3; i++ {
group.Add(2)
go readConfig(group, i)
go writeConfig(group, i)
}
group.Wait()
//写入配置开始2
//写入配置结束2
//读取配置开始2
//读取配置开始0
//读取配置开始1
//读取配置结束1
//读取配置结束2
//读取配置结束0
//写入配置开始1
//写入配置结束1
//写入配置开始0
//写入配置结束0
// 可以看出先获取写锁时,读锁是不可以获取的(写入配置开始2,写入配置结束2);只有写锁释放后读锁才可以获取且读锁之间不互斥(读取配置开始2,读取配置开始0,读取配置开始1,读取配置结束1,读取配置结束2,读取配置结束0)
// 只有等待读锁和写锁全部释放后才可以再次获取写锁(写入配置开始1,写入配置结束1,写入配置开始0,写入配置结束0)
}
// --------------------------------------通道channel-----------------------------
// 1. 不同协程中的数据交互或者数据共享可以使用channel的方式来实现
// 2. channel的方式通常是一个协程生产数据,另一个协程来消费数据,这种方式特别适用于解耦:当比如原流程是A->B俩个同步步骤,每个步骤各需要1s,那么整体流程就是2s,
// 此时可以创建俩个协程,1个协程用于执行A步骤,并把B步骤需要的数据写入到channel中;在创建一个协程用于获取channel中的数据来实行B步骤;由于协程是异步的,也就是
// 这俩协程是可以并行处理任务的,所以通过channel的这种方式最终1s就可以执行完原2s的流程,正是由于协程的这个机制,对于一些高响应高耗时的任务来说非常合适。
// 3. channel也是引用类型,创建channel的方式为make(chan type)
// 4. 如果通道关闭,继续往通道内写入消息则抛panic
// 5. channel分为俩中,一种是阻塞channel,另一种非阻塞channel
// 1> make(chan int)是阻塞channel,只有当生产者和消费者同时就位时
// 6. 必须确保通道只被关闭一次,关闭的通道如果再次关闭则会出发panic
// 7. 关闭通道后,该通道也会收到消息,data,ok := <-channel,data为channel的零值,而ok为false;
// 特别注意:关闭通道后,并不是只往通道发送一条消息,而是只要for循环data,ok := <-channel都可以拉到data为channel的零值,而ok为false的消息
// 全局变量只是申明name2old的类型,零值为nil,需要在后续进行实例话
// 特别注意:这是全局变量,对于协程来说是共享的,由于该例子在协程中是只读操作,所以没有加锁,如果协程中对该变量有写操作的话,则需要在执行写操作的协程中进行加锁和解锁
var name2old map[string]int
var olds int
func init1() {
//特别注意:对于这种全局变量一定要重新申明
name2old = map[string]int{}
name2old["xiaoyu"] = 20
name2old["xiaoxia"] = 21
name2old["liuyu"] = 22
name2old["aini"] = 10
name2old["haha"] = 22
name2old["yuqi"] = 10
}
func getOld(name string, oldChan chan int, group *sync.WaitGroup) {
defer group.Done()
//生产者的逻辑
old := name2old[name]
oldChan <- old
}
func getOld1(names []string, oldChan chan int, group *sync.WaitGroup) {
defer group.Done()
//生产者的逻辑
for _, name := range names {
old := name2old[name]
oldChan <- old
}
//特别注意:关闭生产者的通道,必须放在生产者的协程里,因为只有在全部发送完数据之后才可以关闭通道;如果放在父协程内执行,则有可能消息还没发生
//完成就已经关闭了通道
//还有一个点:为什么要关闭通道呢?背景是什么呢?首先生产者总有发送完消息的时候吧,那么消费者也不能一直等生产发送消息吧,对于这种问题有俩种解决方式
// 1. 生产者和消费者预定固定标识,生产者在生产完消息之后写入特定标识,如果消费者读到了该标识就以为消息没了,消费者可以退出了,这种方式有俩个明显的弊端
// 1> 约定的标识可能和生产的消息一样,这样就容易造成消费者提前退出,有的消息消费不到
// 2> 生产者数量小于消费者数量时,会导致部分消费者收不到约定标识,也就会支持等待,造成资源浪费
// 2. 生产者在生产完数据时,直接close通道,go机制会通过广播的形式给所有的消费者发送关闭通知。
// 1> 当通道被 close() 关闭时,会向所有正在或即将从该通道接收数据的 goroutine 发送广播信号;所有阻塞在接收操作上的 goroutine 会立即被唤醒并返回
// 2> 通道关闭后,消费者可以继续从通道中读取已缓冲但尚未消费的数据;当所有缓冲数据都被读取完毕后,后续的接收操作会立即返回该通道类型的零值,而不会阻塞
close(oldChan)
}
func getSumOld(oldChan chan int, group *sync.WaitGroup) {
defer group.Done()
for {
select {
case old, ok := <-oldChan:
//当ok为false时则代表通道已关闭且数据已读完
//当 ok 为 true 时,表示通道处于激活状态,接收到的数据有效
if !ok {
//
fmt.Println(fmt.Sprintf("结束原始为chan类型int的零值:%v", old))
return
} else {
olds = olds + old
}
}
}
}
// 同步的方式获取所选择用户的年龄总和,通常的步骤如下
func TestOrigin(t *testing.T) {
init1()
names := []string{"xiaoyu", "liuyu"}
for _, name := range names {
// 1. 通过姓名获取年龄 1s
old := name2old[name]
// 2. 获取所选姓名的年龄综合 1s
olds = olds + old
}
fmt.Println(fmt.Sprintf("所选年龄总和为:%v", olds))
}
// -------------错误实例------------
// 获取所选择用户的年龄总和,通常的步骤如下
// 1. 通过姓名获取年龄 1s
// 2. 获取所选姓名的年龄综合 1s
func TestChannel(t *testing.T) {
init1()
names := []string{"xiaoyu", "liuyu"}
group := &sync.WaitGroup{}
chan1 := make(chan int)
group.Add(1)
go getSumOld(chan1, group)
for _, name := range names {
group.Add(1)
go getOld(name, chan1, group)
}
// 输出panic: send on closed channel 顾名思义:往关闭的channel中发生数据
// 可以发现写通道消息的方法getOld是协程调用,也就是异步,而close(chan1)是同步的,那么就会可能造成消息还没有全部写入到通道就发生关闭通道的
// 操作。对于channel来说往关闭的通道内写入数据则会直接跑出panic
close(chan1)
group.Wait()
// 那既然会提前关闭通道,那么把close(chan1) 放到group.Wait()之后,是否可以呢?
//有人说,可以把close(chan1) 放到group.Wait()之后,这样等协程都完成在关闭channel就可以了,但是这里边包含消费者的协程,该协程只有等到close(chan1)
//才会退出,也就是说把close(chan1) 放到group.Wait()之后,则group.Wait()会永远阻塞,程序造成死循环。
fmt.Println(fmt.Sprintf("所选年龄总和为:%v", olds))
}
// 通过协程加channel的方式获取所选择用户的年龄总和
// 一个生产者一个消费者模式
func TestChannel1(t *testing.T) {
init1()
names := []string{"xiaoyu", "liuyu"}
group := &sync.WaitGroup{}
// 主线程创建一个通道,这是一个阻塞通道,channel其实有俩中类型
// 1. make(chan int)阻塞队列:其容量为1,只有生产者和消费者同时在线时才会返回。也就是当生产者写入消息时会阻塞,消费者拉取消息时,生产者则立即可以返回,并可以立即写入消息,
// 理解容量为1的真正含义即可
// 2. make(chan int,10)非阻塞队列,容量为10:即生产者可以在消费者不存在的情况下,连续写入10条消息,当channel的消息大于10时,生产者再次写入消息时则会发送阻塞
chan1 := make(chan int)
group.Add(1)
// 创建一个协程消费者,消费者从通道内消费数据
go getSumOld(chan1, group)
group.Add(1)
// 创建一个协程生产者,生产者往通道内生产数据
go getOld1(names, chan1, group)
// 这里有一个小技巧是先创建消费者,后创建生产者,因为对于阻塞channel来说,只有当生产者和消费者同时就绪时才可以消息互通,那么就可以提前将
// 消费者执行,当生产者一生产消息时,可以立即被消费到
group.Wait()
fmt.Println(fmt.Sprintf("所选年龄总和为:%v", olds))
// 这里可能会有人疑问了?和下面那种同步的方式相比,使用异步协程+阻塞channel的方式代码复杂度高不少,还需要waitGroup,Mutex等,具体收益在哪里?
// 答案就是相比同步的方式,异步协程+阻塞channel响应时间更快,效率更高,因为生产者和消费都是异步的,所以理论上是可以并行执行的,这样就可以减少一部分时间了
// 比如步骤1和步骤2都需要1s中时对比下面俩种方式
// 异步协程+阻塞channel(1个生产者+1个消费者) 同步执行
//-------------------------------------- -----------------------
//|时间 | 生产者任务 | 消费者任务 | |时间 | 任务 |
// -------------------------------------- -----------------------
//| 1S | 任务A步骤1 | | |1s | 任务A步骤1 |
//--------------------------------------- -----------------------
//| 2S | 任务B步骤1 | 任务A步骤2 | |2s | 任务A步骤2 |
//_______________________________________ -----------------------
//| 3S | 任务C步骤1 | 任务B步骤2 | |3s | 任务B步骤1 |
//_______________________________________ -----------------------
//| 4S | | 任务C步骤2 | |4s | 任务B步骤2 |
//--------------------------------------- -----------------------
//通过上面的对比可以很明显的看出异步协程+阻塞channel的方式更优,4s可以执行3个任务,而同步执行的4s只可以执行2个任务。
//上面例子是1个生产者+1个消费者的方式对比结果,这种同一秒最多一个生产者和消费者同时执行,所以和同步执行的看起来效果不是太明显,
//而如果多个生产者多个消费者,也就意味着同一秒可以多个生产者和消费者同时执行, 那相比同步任务的效果就更加明显了
//
//对于阻塞channel,消费者数量1个和多个效果是一样的呢?因为只有生产者和消费者同时在时才可以呢?继续看下面的实验
// 比如步骤1执行需要1s,而步骤2都需要2s时,对于1个消费者和2个消费者的区别(只有一个生产者)
// 一个生产者1s 一个消费者2s 一个生产者1s 俩个消费者2s
//-------------------------------------- --------------------------------------------------------
//|时间 | 生产者任务 | 消费者任务 | |时间 | 生产者任务 | 消费者1任务 | 消费者2任务 |
// -------------------------------------- --------------------------------------------------------
//| 1S | 任务A步骤1 | | | 1s | 任务A步骤1 | | |
//--------------------------------------- --------------------------------------------------------
//| 2S | 任务B步骤1 | 任务A步骤2 | | 2s | 任务B步骤1 | 任务A步骤2 | |
//_______________________________________ --------------------------------------------------------
//| 3S | | | | 3s | 任务C步骤1 | | 任务B步骤2 |
//_______________________________________ --------------------------------------------------------
//| 4S | 任务C步骤1 | 任务B步骤2 | | 4s | 任务D步骤1 | 任务C步骤2 | |
//--------------------------------------- --------------------------------------------------------
//| 5S | | | | 5s | 任务E步骤1 | | 任务D步骤2 |
//--------------------------------------- ---------------------------------------------------------
// 一个生产者和一个消费者,可以发现5s内就执行了俩个任务
// 1. 在第3s的生产者写消息时其实是阻塞的,因为消费者任务A还没有执行完成,消费者没有准备就绪
// 2. 在第4s时消费者消费完任务A,此时消费就绪,生产者则继续写入任务c,而消费者则继续拉去任务B
// 3. 在第5s的生产者写消息时其实是阻塞的,因为消费者任务B还没有执行完成,消费者没有准备就绪
// 一个生产者和俩个消费者,可以发现5s内就执行了4个任务,相比一个消费者效率大大提高
// 1. 在第三秒时,由于消费者1仍在执行任务A,所以不能继续拉去任务,而消费者B则可以继续拉去任务B,由于此时channel的消息为0,则生产者可以继续写入任务C
// 总结:对于阻塞channel来说,多个消费者相比于一个消费者,效率可以大大提高
//生产者是一个还是多个效率更高呢?肯定是多个生产者效率高,那么什么时候使用一个消费者,什么时候使用多个呢?
// 1. 当对任务量不确定时则只能为1个生产者
// 2. 当任务量是明确的,则可以分批处理,比如任务量是100,则可以每10个任务使用一个生产者来生产,但是多生产者模式一定要特别注意正确关闭通道,不能多个生产者多个关闭通道
// 1)可以通过waitGroup来控制
//
// 比如步骤1执行需要2s,而步骤1都需要1s时,对于1个生产者和2个生产者的区别(只有一个消费者)
// 一个生产者1s 一个消费者2s 一个生产者1s 俩个消费者2s
//-------------------------------------- --------------------------------------------------------
//|时间 | 生产者任务 | 消费者任务 | |时间 | 生产者1任务 | 生产者2任务 | 消费者任务 |
// -------------------------------------- --------------------------------------------------------
//| 1S | 任务A步骤1 | | | 1s | 任务A步骤1 | | |
//--------------------------------------- --------------------------------------------------------
//| 2S | | 任务A步骤2 | | 2s | | 任务B步骤1 | 任务A步骤2 |
//_______________________________________ --------------------------------------------------------
//| 3S | 任务B步骤1 | 任务B步骤2 | | 3s | 任务C步骤1 | | 任务B步骤2 |
//_______________________________________ --------------------------------------------------------
//| 4S | | 任务B步骤2 | | 4s | | 任务D步骤1 | 任务C步骤2 |
//--------------------------------------- --------------------------------------------------------
//| 5S | 任务C步骤1 | 任务C步骤2 | | 5s | 任务E步骤1 | | 任务D步骤2 |
//--------------------------------------- ---------------------------------------------------------
// 总结:对于阻塞队列和非阻塞队列来说,多生产者和多消费者都比单生产者或者单消费者效率要高
}
// 虽然通道关闭了,但是每次获取通道消息都可以拉到值,data为channel的零值,ok为false
func TestChan(t *testing.T) {
done := make(chan int)
close(done)
//for {
// select {
// case data, ok := <-done:
// fmt.Println(fmt.Sprintf("%v,%v", data, ok))
// }
//}
for i := 0; i < 5; i++ {
//这里会输出
//0,false
//0,false
//0,false
//0,false
//0,false
// 也就是虽然通道关闭了,但是每次获取通道消息都可以拉到值,data为channel的零值,ok为false
data, ok := <-done
fmt.Println(fmt.Sprintf("%v,%v", data, ok))
}
}
// 是不是所有的子协程都需要传入父协程的cxt呢?是不是所有的子协程都需要监听父协程的cxt.Done()呢?
//
// 1.对于执行可能被取消的长时间运行任务(如网络请求、I/O操作、复杂计算等)的协程,必须通过select语句监听ctx.Done()通道,以便在父协程取消时能及时退出,避免资源浪费和内存泄漏 。
// 例如,处理上传文件或调用外部API的协程,如果不对取消信号做出响应,即使主请求已结束,它们仍会继续运行并占用系统资源
// 2.执行短期、快速且必须完成的任务的协程通常无需监听ctx.Done()。这些任务通常在毫秒或秒级内完成,其短暂的生命周期使得强制中断的收益不大,反而可能增加逻辑复杂性。但是需要知道
// 的是,如果子协程不监听父协程的cxt.Done(),当父协程取消退出时,此时子协程仍然在运行,直到运行结束
func getOldTreeProducer(cxt context.Context, list []employee, oldChan chan int, group *sync.WaitGroup) {
defer func() {
//这里做资源回收
group.Done()
close(oldChan)
}()
fmt.Println(fmt.Sprintf("入参数input为:%v", cxt.Value("input").(string)))
timeOutCxt, _ := context.WithTimeout(cxt, time.Second*2)
//这里启用了3个生产者协程,为了使生产者都生产完消息后在正确关闭通道,所以这里要单独设置一个&sync.WaitGroup,专门用于生产者
wait := &sync.WaitGroup{}
for _, data := range list {
wait.Add(1)
go func(data employee) {
defer wait.Done()
//打开这行代码可以测试timeOutCxt超时场景
//time.Sleep(time.Second * 3)
oldChan <- data.age
}(data)
// 这里可能有个疑问?为什么只传入了data变量,而wait变量和oldChan都没传入,这样会有问题吗
// 是没问题的,因为而wait变量和oldChan变量都是引用地址,传入方法和不传入都是一样的,而data是employee类型,属于struct类型,struct
// 是属于值类型,对于值类型的变量则必须传入。
// 总结:对于引用类型的变量如果外层能够获取到,则无需传入到协程中;对于值类型的变量则必须通过参数传入到协程中,如果不通过参数传入,则必须使用
// &sync.mutex来加锁,保证协程安全
}
// 这段代码是一个小技巧,一定要学会
done := make(chan struct{})
go func() {
//time.Sleep(time.Second * 3)
wait.Wait()
close(done)
}()
for {
select {
//这里监听了timeOutCxt就可以了,因为timeOutCxt是继承了cxt,那么cxt取消时,timeOutCxt.Done()也会收到消息,同理timeOutCxt取消时,timeOutCxt.Done()也会收到消息
//注意:当创建了子context时,记住只需要监听子context.Done即可
case <-timeOutCxt.Done():
fmt.Println("生产者耗时超过2s或者cxt收到退出通知")
return
case <-done:
fmt.Println("生产者任务完成")
return
}
}
}
/**
分3个生产者 和3个消费者来获取所有任务的年龄总和
*/
// ------------------这里详细介绍下context----------------------------
//context出现的背景
// 1. 设计之初,协程是没有协程id的,不像进程pid或线程tid,所以想kill调一些协程时是比较困难的,所以需要一种能使协程正常退出的机制
// 2. 正常情况下主协程因为某些原因退出后(如进程被kill或者超时),避免子线程仍然执行。主线程退出后子协程也应该退出,如果子协程不退出则变成僵尸协程,这就造成了资源浪费和内存泄漏
//正是基于这两个背景,context应运而生,就是为了主协程退出后,也能保证子协程也正常退出。有没有发现和&sync.waitGroup很相似,waitGroup是为了不让主协程提前退出,而是等待子协程都
//执行完成后,主协程在退出;也是为了避免主协程退出后而子协程仍在执行的,这种子协程也变成僵尸协程,这就造成了资源浪费和内存泄漏。
//所以context和.waitGroup的出发点是不同的,但作用都是相同的
// 1. context出发点是程序异常(如提前kill掉进程)导致主协程异常退出了,这种情况下子协程也要提前退出,否则子协程会成为僵尸协程,这就造成了资源浪费和内存泄漏。
// 2. waitGroup出发点是程序是正常运行,由子协程是异步的,所以主协程必须等待子协程执行完后主协程才可以退出,否则主协程提前退出的话,子协程会成为僵尸协程,这就造成了资源浪费和内存泄漏。
//context的创建方式有4种
// 1. WithCancel():创建可取消的Context,返回的是context和cancel()
// 2. WithTimeout(): 创建带超时的Context, 返回的是context和cancel()
// 3 .WithDeadline():创建带截止时间的Context,返回的是context和cancel()
// 4. WithValue():创建带键值对的Context,返回Context
//context的主要作用有三个
// 1. 取消;WithCancel、WithDeadline都会返回cancel()和context, 当调用cancel()时,context.Done()该channel将会关闭通道,<-context.Done()将会会收到消息,
// 也就是说context.Done()平时是阻塞的,只有调用context的cancel()时context.Done() channel才会收到消息,当子协程
// 收到该channel的消息时,可根据策略觉得是立即return,还是继续执行,还是说会滚之前任务状态
// 2. 超时: WithTimeout返回的是context,也就是说所属协程在所设定的时间还没有返回时context.Done()会关闭通道,<-context.Done()将会会收到消息
// 2. 传递数据:WithValue(),可以设置key value的方式设置值,如果是一个http服务,可以将请求参数传入context,因为context是伴随整个生命周期的。
//context是一个树状结构,子context继承父context的所有属性,父context不继承子context的属性,当父Context取消时,所有派生的子Context也会收到取消的消息。这种机制特别适用于需要级联取消的分布式系统任务
// 父 cancelCxt,cancel := context.WithCancel(context.Background())
// 子 valueCxt,valueCancel := context.WithValue(cancelCxt,"input","{'name':'hello','age':20}")
// 孙 timeOutCxt,timeOutCancel := context.WithTimeout(valueCxt,time.Second * 3)
// 1. 当调用cancel()时,cancelCxt.Done()、timeOutCxt。Done()、valueCxt.Done()通道都会收到关闭通知
// 2. 当调用valueCancel()时,timeOutCxt。Done()、valueCxt.Done()通道都会收到关闭通知,而cancelCxt.Done()不会收到通知
// 3. 孙context timeOutCxt.Value("input").(string)也可以获取到子context传入的key
//特别注意:context应该作为协程方法的必要入参,每个协程都需要传入context参数,且cancelCxt、valueCxt或者timeOutCxt是一个interface,也就是说context是一个引用地址
//
//这里肯定有个疑问点?context是怎么实现主协程退出后,也能保证子协程也正常退出的呢?
// 1. 通常是将父协程的根context作为子协程的入参数传入,而子协程一直监听父协程的context.Done()通道的消息,如果有消息则代表父协程退出,则该子协程也应该退出了
// 2.利用context的树状特性和子协程传入父协程的context的方式,只要父协程执行cancel,则子协程,孙协程...都可以收到父协程的context.Done()通道的取消消息,既而孙子协程可以根据自身实际情况做出任务取消的逻辑。
//
//对于协程编码来说,比较复杂,很容易出现主线程已经结束,子协程还在运行的情况,run或者debug之后出现PASS之后还有输出情况,,则代表主线程已经结束,子协程还在运行;pass是表主线程已经结束,后面仍有输出则子协程在主协程退出后仍然执行
// === RUN TestManyProducer
// --- PASS: TestManyProducer (0.00s)
// PASS
// 消费者收到退出通知
func TestManyProducer(t *testing.T) {
employee1 := []employee{}
employee1 = append(employee1, employee{"xiaoyu", 12})
employee1 = append(employee1, employee{"liuyu", 16})
employee1 = append(employee1, employee{"baba", 21})
employee1 = append(employee1, employee{"laba", 20})
group := &sync.WaitGroup{}
chan1 := make(chan int)
// 创建一个可取消的cxt
cancelCxt, cancel := context.WithCancel(context.Background())
//继承cancelCxt,并添加key
valueCxt := context.WithValue(cancelCxt, "input", "{'old':20}")
// 三个消费者
for i := 0; i < 3; i++ {
group.Add(1)
go func(i int) {
defer group.Done()
// 1. 多路选择select:我们知道select语句本身是阻塞操作,会等待任意一个case对应的channel操作就绪,并执行对应的case。
// 2. select的第二个case是获取<-chan1的消息,需要循环获取,直接chan1通道关闭,所以对于消费者来说,必须使用for select的结构
// 3. 对于for select的结构特别注意的一点是,命中某个case之后一点要记得return,退出for select结构,不然会无限循环下去
for {
select {
// 收到父协程的取消任务通知,则子协程也做取消任务,直接return
case <-valueCxt.Done():
fmt.Println("消费者收到退出通知")
return
case old, ok := <-chan1:
if !ok {
// 收到chan1通道关闭的消息,则可以退出for select循环,这里返回return
fmt.Println(fmt.Sprintf("消费者%v任务完成", i))
return
} else {
// 收到chan1的消息,且chan1通道未关闭,则不return,继续接受chan1通道的消息
olds = olds + old
}
}
}
}(i)
// 这里可能有个疑问?为什么group变量和chan1和cancelCxt都没传入,这样会有问题吗
// 是没问题的,因为而group变量和chan1变量都是引用地址,而cancelCxt是interface,也是属于引用地址,传入方法和不传入都是一样,而如果协程内的操作的变量是值类型的,则该变量则必须传入。
// 总结:对于引用类型的变量如果外层能够获取到,则无需传入到协程中;对于值类型的变量则必须通过参数传入到协程中,如果不通过参数传入,则必须使用
// &sync.mutex来加锁,保证协程安全
}
group.Add(1)
// 创建多个生产者是难点,因为考虑的点是,只有多个生产者都在生产完消息后才可以关闭channel
// 1. close channel必须是在getOldTreeProducer协程内关闭
// 2. 使用&sync.waitGroup来等待所有生产者生产完任务之后在关闭channel
go getOldTreeProducer(valueCxt, employee1, chan1, group)
//一个用于接收操作系统信号的缓冲通道
sign := make(chan os.Signal, 1)
//这里运用一个小技巧
//done := make(chan struct{})
// go func() {
// group.Wait()
// close(done)
// }() 其实group.Wait() close(done)就可以代替这段代码,效果是完全一样的,也不会出现内存泄漏的情况,使用新起一个协程这种方式有哩个原因
// 1. 原方式会阻塞主线程,新方式使用新起一个协程+通道的方式+for select <-channel的方式,可以避免主线程阻塞
// 2. 如果线程需要监控cxt.Done()的消息,则必须使用这种for select的新方式,同时监控cxt.Done()和done,谁先收到消息则退出循环;
// 如果done先收到消息则表示任务执行完成,主线程可以退出;如果cxt.Done()收到消息则代表主线程需要取消任务,主线程也可以退出
//如果不使用协程+通道的方式+for select <-channel的这种方式,那代码只能是这样写。close(done)就代表任务已经执行完成了,后面在for select
//则会造成死循环,该程序只有在收到cxt的cancel()时才会退出,我们理想的程序是收到cxt的cancel()或者程序正常结束时都应该退出。
//group.Wait()
//close(done)
//for {
// select {
// case signType := <-sign:
// switch signType {
// case syscall.SIGKILL:
// fmt.Println("程序被kill掉,提前退出")
// cancel()
// return
// }
// }
// }
done := make(chan struct{})
go func() {
group.Wait()
close(done)
}()
//多路选择和超时控制
// 1. select语句中本身就是阻塞操作,会等待任意一个case对应的阻塞操作就绪,同时执行对应case后面的逻辑,最终退出select.
// 2. 如果select中有default,那么当所有的case都在阻塞时,则会运行default后的逻辑,并最终退出select
// 3. 由于select中的所有case都是阻塞的,所以通常需要<-time.After(time)来控制阻塞时长,当select的阻塞时间大于time时,则select退出阻塞
// 4. 对用的case执行后不需要手动return或者break,会自动退出select循环
// 5. 本次select中的所有case,只要又一个case就绪就可以退出了,不需要循环for select结构
select {
// done通道关闭后,<-done会收到消息,收到消息后则直接退出,代表程序运行完成
case <-done:
fmt.Println("程序正常运行完成")
fmt.Println(fmt.Sprintf("所选年龄总和为:%v", olds))
case signType := <-sign:
switch signType {
case syscall.SIGKILL:
fmt.Println("程序被kill掉,提前退出")
cancel()
case syscall.SIGINT:
fmt.Println("程序被control+c,提前退出")
cancel()
}
//time.After也是一个channel,通过设置时间可以达到超时控制功能,因为这个for循环不能一直循环吧,如果<-done一直没有收到信息则无限时长循环到这里了,
//所以可以加一个超时控制,当5s之后,没有收到<-done或者 <-sign消息,则主动退出程序
case <-time.After(time.Second * 5):
cancel()
}
}
// 多路选择与超时控制
func TestSelec(t *testing.T) {
aa := make(chan int)
select {
case <-aa:
fmt.Println("hah")
case <-time.After(time.Second * 3):
fmt.Println("超时3s退出")
}
fmt.Println("bb")
}
// 上面介绍了sync.waitGroup,sync.mutex,接下来继续介绍下sync的相关组建
// -------------------sync.once---------------------------
// 在多线程中希望只被访问一次,例如单例模式
// 1. &sync.once 必须是全局变量,是需要多协程共享的,且直接赋值
// 2. 多线程执行&sync.once.Do(func())时,可以保证func()只被执行一次
type car struct{}
var instance car
// 全局变量初始化(之前以为不能初始化呢)
// &sync.Once 必须是全局变量,因为是多协程之间保证只执行一次,那么肯定是需要同一个&sync.Once来保证实现
// 全局变量初始化是var关键字加上=,注意不是:=
var once = &sync.Once{}
func init2() car {
once.Do(func() {
fmt.Println("只进来一次")
instance = car{}
})
return instance
}
func TestOnce(t *testing.T) {
group := &sync.WaitGroup{}
for i := 0; i < 5; i++ {
group.Add(1)
go func() {
instance := init2()
//返回的instance的地址都是同一个
fmt.Println(fmt.Sprintf("地址为%v", unsafe.Pointer(&instance)))
group.Done()
}()
}
group.Wait()
//只进来一次
//地址为0x1013438a0
//地址为0x1013438a0
//地址为0x1013438a0
//地址为0x1013438a0
//地址为0x1013438a0
}
// ---------------------------sync.map------------------------------
// 这是个原生map,在goroutine并发读写或者并发写写时都会直接panic。
//
// 1.如果map在读取过程中被其他goroutine修改,读取方可能会获取到不一致的数据状态,即使只有一个写操作配合任意数量的读操作(并发读写),同样会触发fatal error: concurrent map read and map write
// 2.当多个goroutine同时对map进行写操作时,会破坏map的内部数据结构,直接引发fatal error: concurrent map writes
// 3.当多个goroutine同时对map进行读写操作时,即使没有上面俩种报错,也会发送一些发未定义的行为,包括数据错乱,可以看下面这个例子
// 4.如果多goroutine之间只有读操作,肯定没有写操作,这种情况下原生map是并发安全的;多goroutine之间只要有map的写操作,那么原生map就是并发不安全的。
//
var map1 = make(map[string]int)
func updateMap(group *sync.WaitGroup) {
defer group.Done()
//读取map
if sum, ok := map1["sum"]; ok {
//写入map
map1["sum"] = sum + 1
} else {
//写入map
map1["sum"] = 0
}
//sum值为010 <nil>
//sum值为110 <nil>
//sum值为210 <nil>
//sum值为310 <nil>
// 可以看出上面的输出其实不是我们的预期输出,预期应该是0 1 2 3这种,可见发生了数据的错乱
fmt.Println(fmt.Printf("sum值为%v", map1["sum"]))
}
// 这是一个多goroutine之间使用原生map并发读写的案例,就发送了一些未定义的行为,包括数据错乱
func TestSyncMap(t *testing.T) {
group := &sync.WaitGroup{}
for i := 0; i < 10; i++ {
group.Add(1)
go updateMap(group)
}
group.Wait()
}
// 上面知道了原生的map在多goroutine并发读写的情况下出现的问题,接下来使用sync.map来解决
// 注意这里并没有像原生map定义key和value的类型
var map2 = &sync.Map{}
var lock = &sync.Mutex{}
func updateMap1(group *sync.WaitGroup) {
defer group.Done()
//读取map
//注意返回值value any, ok bool,value是一个interface,所以需要断言
sum, ok := map2.Load("sum")
if ok {
//断言为int
sum = sum.(int) + 1
//写入map 没有返回值
map2.Store("sum", sum)
} else {
//写入map
map2.Store("sum", 0)
}
fmt.Println(sum)
//1
//2
//3
//4
//5
//4
//6
//8
//7
// 发现返回了俩个4?问题原因是在1076行sum, ok := map2.Load("sum")
// 1. sync.map是并发安全的,怎么理解这个并发安全呢?只是相对于原生map时并发读写或者并发写写不会panic。不要理解为并发时都不需要加锁了,数据时完成正确的
// 2. sync.map是使用俩个map来解决并发读写的,读map不加锁,多goroutine之间可以并发读,保证高效能,写map是写互斥锁,写并发时会阻塞。
// 3. 出现上面的问题,原因是 map2.Load("sum")时并没有加锁,导致并发时可能获取到的值是一样的,也就导致map2.Store("sum", sum)写入sum值可能是相同的,也就出现了俩个4
// 4. 本质原因是 map2.Load("sum")读锁和map2.Store("sum", sum)写锁不是原子性的,所以如果一个goroutine中涉及到多种类型的sync.map的操作时,往往会有原子性问题,一定要注意,所以一个goroutine要不写要不读,实在不行就加锁
// 5. 其实sync.map提供了很多原子性操作,具体可以看下下面的操作,目的就是在多goroutine可以并发安全正确的操作
// 解决这个问题也很简单,有以下几种方式
// 1. 使用sync.map加锁
// lock.Lock()
// sum, ok := map2.Load("sum")
// if ok {
// //断言为int
// sum = sum.(int) + 1
// //写入map 没有返回值
// map2.Store("sum", sum)
// } else {
// //写入map
// map2.Store("sum", 0)
// }
// lock.Unlock()
// 2.
}
// 那么对于原生map并发不安全的情况,通常有俩种解决方式
//
// 1.互斥锁:通过sync.Mutex或者sync.RWMutex来控制map的访问
// 2.sync.map : 采用空间换时间的策略,通过两个map(read map和dirty map)实现读写分离,read map使用原子操作保证高性能读取,dirty map则在需要写入时通过互斥锁保护 ,这种设计还通过entry封装value,实现了延迟删除机制
// 1.sync.map.Load(key) 获取key的值,获取的值是any类型,需要进行断言
// 2.sync.map.Store(key,value) 存储key的值为value
// 3.sync.map.Delete(key) 删除key
// 4.sync.map.Range() 遍历整个map,是无序的,且需要通过回调函数来实现遍历,func(key, value interface{}) bool
func TestSyncMap1(t *testing.T) {
group := &sync.WaitGroup{}
for i := 0; i < 10; i++ {
group.Add(1)
go updateMap1(group)
}
group.Wait()
sum, _ := map2.Load("sum")
fmt.Println(fmt.Sprintf("总和sum值为:%v", sum))
//删除key
map2.Delete("sum")
//存储key
map2.Store("哈哈", 12)
map2.Store("呀呀", 14)
map2.Store("啦啦", 20)
//遍历key,当返回 true 时继续遍历下一个键值对,返回 false 时立即停止遍历
//由于 sync.Map 的键和值都是 interface{} 类型,通常需要进行类型断言来获取具体的类型
map2.Range(func(key, value any) bool {
fmt.Println(fmt.Sprintf("key为%v,value为%v", key, value))
return true
})
//原子操作:判断参数key是否存在,返回值 (actual any, loaded bool)
// 1.参数key存在时:则actual参数返回该key值且loaded 返回 true
// 2.参数key不存在时:则赋值参数key值为参数value值,且actual返回新存储的value,loaded 返回 false
data, status := map2.LoadOrStore("哈哈", 34)
if status {
fmt.Println(fmt.Sprintf("key为哈哈,已经存在,值为%v", data))
} else {
fmt.Println(fmt.Sprintf("key为哈哈,不存在,值为%v", data))
}
//原子操作:判断参数key是否存在,(value any, loaded bool)
// 1.如果key存在:则删除该key,value返回原key值,loaded返回true
// 2.如果key不存在:则value返回nil(any为引用类型,引用类型的零值为nil),loaded为false
data, status = map2.LoadAndDelete("来来来")
if !status {
fmt.Println(fmt.Sprintf("key为来来来不存在,data为%v", data))
}
//原子操作:判断参数key的值是否等于old参数,返回值为(deleted bool)
// 1.如果相等:删除该key,并返回true
// 2.如果不相等:不执行删除操作,并返回false
// 3.如果该key不存在:不删除,并返回false
status = map2.CompareAndDelete("哈哈", 12)
if status {
fmt.Println("key为哈哈存在,且值为12,删除成功")
} else {
fmt.Println("key为哈哈存在,但是值不为12,删除失败")
}
//原子操作:判断参数key的值是否等于old参数,返回值为bool
// 1. 如果key存在且值为old:则将该key的值更新为new,并返回true
// 2. 如果key存在且值不为old:则不进行更新操作,并返回false
// 3. 如果key不存在,则直接返回false
status = map2.CompareAndSwap("呀呀", 14, 50)
if status {
fmt.Println("key为呀呀存在,且值为14,则成功更新为50")
} else {
fmt.Println("key为呀呀更新失败")
}
}
// --------------------------http服务--------------------
// go test go_test.go 对于_test.go的文件来说,只能用go test命令运行,运行后会执行所有Test开始的function
// go test -run testFunc test.go 如果只想运行test文件中的某个方法,则可以使用这种方式指定执行
// go run a.go 可执行程序的入口必须是 main 包中的 main 函数,也就是a.go文件必须属于main 包,且含有main 函数
// go run 执行当前目录下main包下的main函数(需注意,该目录下只有有一个main包下的main函数,如果多个,build时则报错)
// go build a.go 是会对a.go文件生成一个可执行文件,然后执行该文件,默认会从 main 包中的 main 方法开始执行
// go build 编译当前目录,生成与当前目录一致的可执行文件,执行文件时执行main包下的main函数(需注意,该目录下只有有一个main包下的main函数,如果多个,build时则报错)
// 下面这个是一个http服务,通常希望程序可以一直运行(如kill或者资源不足时也可以退出),可以使用下面这种方式
//
// /usr/bin/nohup go run a.go >> b.txt 2>&1 & 或者 /usr/bin/nohup ./a >> b.txt 2>&1 &
// 1.nohup: 是一个二进制命令,可以保证关闭当前窗口或者当前终端时服务仍然运行,即使SSH连接断开或终端关闭,进程也不会被终止。如果在A窗口执行go run a.go,然后关闭A窗口后该服务则会被杀死,加上nohup则不会
// 2.>> b.txt表示:将程序的标准输出写入到b.txt文件中
// 3.2>&1: 重定向标准错误到标准输出的文件,也就是标准错误也写入到b.txt文件中。2代表标准错误(stderr),1代表标准输出(stdout);&1表示引用标准输出的目标位置;将错误输出也重定向到同一个日志文件中
// 4.& :表示后台运行;将进程放入后台运行,不阻塞当前终端;用户可以继续在终端中执行其他命令
func TestHttp(f *testing.T) {
http.HandleFunc("/a", func(w http.ResponseWriter, r *http.Request) {
//终端会输出
fmt.Println("hello")
})
http.HandleFunc("/b", func(w http.ResponseWriter, r *http.Request) {
//页面上会输出
time.Sleep(time.Second * 5)
w.Write([]byte("很高兴认识你"))
})
http.HandleFunc("/c", func(w http.ResponseWriter, r *http.Request) {
for i := 0; i < 10; i++ {
go profile1111()
}
w.Write([]byte("test start"))
})
fmt.Println("开始连接")
//会hold住
http.ListenAndServe(":8080", nil)
}
func profile1111() {
time.Sleep(time.Second * 100000)
}
// -------------------------------------benchmark基准测试----------------------------------
// 为什么要使用基准测试呢? 当对于同一个问题有多种实现方案时,不知道哪种方案更好性能更优时,则可以使用基准测试来进行对比。最常用的一种场景时引入第三方库时哪个库效果更好?则可以使用benchmark来进行测试
// 1. go test -bench=. 执行当前目录下的所有_test.go文件中的测试函数(Test开头)和基准测试函数(Benchmark开头)
// 2. go test x_test.go -bench=. 执行x_test.go文件中的测试函数(Test开头)和基准测试函数(Benchmark开头)
// 3. go test x_test.go -bench=. -run=^$ 只执行x_test.go文件中的基准测试函数(Benchmark开头)
// 4. go test x_test.go -bench=. -run=^$ -benchtime=3s 默认的测试时间是1s,可以通过 -benchtime参数来指定基准测试的时长
// 参数如下:
// a. -benchtime 3s: 指定测试时间
// b. -benchmem:打印出申请内存的次数。一般用于简单的性能测试,不会导出数据文件
// c. -blockprofile block.out: 将协程的阻塞数据写入特定的文件(block.out)。如果-c,则写成二进制文件
// d. -cpuprofile cpu.out: 将协程的CPU使用数据写入特定的文件(cpu.out)。如果-c,则写成二进制文件
// e. -memprofile mem.out: 将协程的内存申请数据写入特定的文件(mem.out)。如果-c,则写成二进制文件
// f. -mutexprofile mutex.out: 将协程的互斥数据写入特定的文件(mutex.out)。如果-c,则写成二进制文件
// g. -trace trace.out:将执行调用链写入特定文件(trace.out)
//
// bench=. 代表的是执行所有Benchmark开头的函数,这里是可以使用正则匹配执行的函数的
//
// BenchmarkJoinStr-8 21444 111678 ns/op 3555266 B/op 10 allocs/op
// BenchmarkBufferStr-8 28842253 46.50 ns/op 24 B/op 0 allocs/op
// 这是执行go test -bench=. -run=^$ -benchtime=3s -benchmem 命令行返回的结果
// 1. BenchmarkJoinStr-8:测试函数名,-8表示使用8个CPU核心
// 2. 21444表示在3s内执行了21444次BenchmarkJoinStr函数;28842253表示3s内执行了28842253次BenchmarkBufferStr函数
// 3. 111678 ns/op:每次操作平均耗时111,678纳秒(约0.11毫秒);46.50 ns/op:每次操作平均耗时46.5纳秒
// 4. 3555266 B/op: 每次操作分配3555266 byte内存;24 B/op每次操作分配24 byte内存
// 5. 10 allocs/op:每次操作发生10次内存分配;什么情况下会发生内存分配呢?
// a.使用make()创建切片、映射,或使用new()分配对象
// b.当局部变量在函数返回后仍被引用时,会从栈逃逸到堆
// c.字符串拼接:频繁的字符串连接操作会产生大量临时对象
// 多次内存分配,会造成频繁分配会触发垃圾回收,影响程序性能;内存分配本身需要时间,分配次数越多耗时越长;大量小对象分配可能导致内存利用率下降
// 总结:可以看出在相同的时间内,ns/op和allocs/op 越小性能越优秀,BenchmarkBufferStr执行的次数更多且每次执行的时间更少,可以看出BenchmarkBufferStr方法对于拼接字符串更优秀
// 注意:基准测试只能_test.go文件中进行测试
// 基准测试就是让不同的程序在相同的时间内执行,对比程序之间执行的时间和消耗的内存
// 使用+来拼接字符串
// go test -bench=JoinStr -run=^$ 表示执行BenchmarkJoinStr为开头的方法,所以BenchmarkJoinStr和BenchmarkJoinStrBuffer方法都回执行基准测试
// go test -bench=BenchmarkJoinStr$ -run=^$ 表示只执行BenchmarkJoinStr方法来进行基准测试
func BenchmarkJoinStr(b *testing.B) {
//b.StartTimer()用于开始会在恢复计数;b.ResetTimer()用于重置即使
b.ResetTimer()
var joinStr string
// b.N是不固定的,执行次数是执行时间动态调整得出的,执行时间默认为1s.通过参数-benchtime调整
// 测试的核心代码放在for i := 0; i < b.N; i++下面
for i := 0; i < b.N; i++ {
elem := []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"}
for _, v := range elem {
joinStr += v
}
}
//停止计数
b.StopTimer()
}
// 使用bytes.Buffer来拼接字符串
func BenchmarkJoinStrBuffer(b *testing.B) {
var buffer bytes.Buffer
b.ResetTimer()
b.StartTimer()
for i := 0; i < b.N; i++ {
elem := []string{"1", "2", "3", "4", "5", "6", "7", "8", "9", "10"}
for _, v := range elem {
buffer.WriteString(v)
}
}
b.StopTimer()
}
// ------------------pprof性能调优-------------
// 具体看这篇文章吧https://zhuanlan.zhihu.com/p/396363069
2025-12-09
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。
相关阅读更多精彩内容
- 为帮助学生突破图形面积学习的问题,2025年12月,徐建宇校长为五年级四个班上了《多边形面积》这节课,效果特别好,...
- 深化立法领域改革,具体要早到以下几点: 第一、完善以宪法为核心的中国特色社会主义法治体系,努力让每一项立法都符合宪...