基础

00-程序结构 阅读更多

0.1. 名称 0.2. 声明 Go语言的大程序都从小的基本组件构建而来。 变量存储值 简单表达式通过加减操作合并成为大的 基本类型通过数组和结构体进行聚合 表达式通过if和for等控制语句来决定执行的顺序 语句被组织成函数用于隔离和复用 函数组织成源文件和代码包 0.1. 名称 Go中函数、变量、常量、类型、语句标签和包的名称都遵循如下规则:名称的开头是一个字母(Unicode中的字符即可)或者下划线,后面可以跟任意数量的字母、数字和下划线,对大小写敏感。 Go有25个关键字:只能用在语法允许的地方: 字母顺序 关键字 b break c case、chan、const、continue d default、defer e else f func、fallthrough、for g go、goto i interface、if、import m map p package r range、return s select、struct、switch t type v var Go有30多个内置的预声明的常量、类型和函数: 种类 关键字 常量 true、false、iota、nil int类型 int、int8、int16、int32、int64 unit类型 uint、uint8、unit16、uint32、unit64、uintptr float类型 float32、float64、complex64、conplex64 其他类型 bool、byte、rune、string、error 函数 make、len、cap、new、append、copy、close、delete、complex、real、imag、panic、recover 这些名称不是预留的,可以在声明中使用它们(即对这些名称进行重声明),但是这样是有冲突风险的。 如果一个实体在函数内声明。它只在函数局部有效 如果一个实体在函数外声明,它将对包里面的所有源文件可见 实体的第一个字母是否大写决定了其可见性是否跨包。如fmt包中的Printf函数,包名总是小写字母组成。 名称的长度没有限制,通常作用域越大,就是越长且有意义的名称。当由多个单词组成时,通常采用驼峰式的风格,而不是下划线。当单词中包含首字母缩写组成的单词时(如ASCII、HTML这样的),通常使用相同的大小写(全大写或全小写)。 0.2. 声明

01-GOPATH和工作区 阅读更多

0.1. Go语言源码的组织方式 0.2. 源码安装后的结果 0.2.1. 举个例子 0.3. 构建和安装Go程序的过程 0.3.1. 构建 0.3.2. 安装 0.4. 总结 版本的特性: Go1.5 自举,即通过Go语言编写程序来实现Go语言自身 Go1.7 极速垃圾回收器 Go1.10 对自带工具全面升级 做程序依赖管理的go mod命令 环境变量: GOROOT:Go语言安装根目录的路径,即Go语言的安装路径 GOPATH:若干工作区目录的路径,用户自定义的工作空间【背后的概念最多,最重要】 GOBIN:Go程序生成的可执行文件的路径 设置GOPATH有什么意义? GOPATH是Go语言的工作目录,它的值是一个或多个目录的路径,每个目录都代表Go语言的一个工作区(Workspace)。 利用工作区去存放: Go语言的源码文件(source file) 安装后的归档文件(archive file,以.a为扩展名的文件) 可执行文件 事实上,Go语言项目在其生命周期内的所有操作(编码、依赖管理、构建、测试、安装等)基本上都是围绕着GOPATH和工作区进行的。 GOPATH背后有三个知识点: Go语言源码的组织方式是怎样的 源码安装后的结果(只有在安装后,Go语言源码才能被其他代码使用) 理解构建和安装Go程序的过程(开发问题以及查找问题的时候非常有用) 0.1. Go语言源码的组织方式 Go语言的源码以代码包为基本组织单位,在文件系统中,这些代码包是与目录一一对应的(目录有子目录,所以代码包有子包)。 一个代码包中包含多个以.go为扩展名的源码文件,这些源码文件都需要被声明属于同一个代码包。 代码包的名称一般会与源码文件所在的目录同名。(如果不同名,在构建安装过程中以代码包的名字为准)。 每个代码包都会有导入路径(即,其他代码在使用该包中的程序实体时,需要引入的路径)。 在实际使用程序实体之前,必须先导入其所在的代码包。具体方式就是import该代码包的导入路径: import "github.com/labstack/echo" 在工作区中,一个代码包的导入路径实际就是从src子目录到该包的实际存储位置的相对路径。 Go语言源码的组织方式就是以环境变量GOPATH、工作区、src目录和代码包为主线的。 一般情况下,Go语言的源码文件都需要被存放在环境变量GOPATH包含的某个工作区中的src目录下的某个包中。 0.2. 源码安装后的结果 Go语言源码在安装后会产生什么样的结果?源码文件以及安装后的结果文件(归档文件以.a为扩展名)会存放在哪里? 源码文件会存放在工作区的src子目录中,安装后的归档文件会存放在工作区的pkg子目录中,如果安装后产生了可执行文件,就可能会放在该工作区的bin子目录中。 源码文件是以代码包的形式组织起来的,一个代码包对应一个目录,安装某个代码包而产生的归档文件是与这个代码包同名的。放置它的相对目录就是该代码包的导入路径的直接父级。 0.2.1. 举个例子 一个已经存在的代码包的导入路径如下: github.com/labstack/echo // 该代码包的源码文件存在与GitHub网站的labstack组的代码仓库echo中 执行安装命令后: go install github.com/labstack/echo 生成的归档文件的相对目录就是github.com/labstack,文件名为echo.a。 上述例子中。代码包的归档文件就会被放置在当前工作区的子目录:pkg/linux_amd64/github.com/labstack中。 注意:在归档文件的相对目录与pkg目录之间还有一级目录,叫做平台相关目录。 平台相关目录的名称是由build(构建)过程的:目标操作系统+下划线+目标计算机架构的代号三部分组成。 总之,某个工作区的src子目录下的源码文件在安装之后,一般会被放置在当前工作区的pkg子目录下对应的目录中,或者被直接放置在该工作区的bin子目录中。如下图所示: 0.3. 构建和安装Go程序的过程 构建使用go build 安装使用go install 构建和安装代码包的过程中都会执行编译、打包等操作,并且操作生成的任何文件都会先被保存在某个临时的目录中。 0.3.1. 构建 如果构建的是库源码文件,那么操作后产生的结果文件只会存在于临时目录中。这里的构建主要意义在于检查和验证。 如果构建的是命令源码文件,那么操作的结果文件会被搬运到源码文件所在的目录中。 0.3.2. 安装 安装操作会先执行构建,然后还会进行链接操作,并且把结果文件搬运到指定目录。 如果安装的是库源文件,那么结果文件会被搬运到它所在工作区的pkg目录下的某个子目录中。 如果安装的是命令源文件,那么结果文件会被搬运到它所在工作区的bin目录中,或者环境变量GOBIN指向的目录中。 0.4. 总结 Go语言提供的很多工具都是在GOPATH和工作区的基础上运行的,比如,go build、go install、go get等。

02-命令源码文件 阅读更多

0.1. 命令源码文件 0.1.1. 构建 0.1.2. 安装 0.2. 库源码文件 0.2.1. 构建 0.2.2. 安装 0.3. 测试源码文件 0.3.1. 功能测试源码文件 0.3.2. 性能(基准)测试源码文件 0.3.3. 示例(样本)测试源码文件 0.4. 命令源码文件的用途是什么?如何编写 0.4.1. 命令源码文件如何接收参数 0.4.2. 如何在运行命令源文件的时候传入参数,如何查看参数的使用说明 0.4.3. 如何自定义命令源文件的参数使用说明 0.4.4. 深入一些 0.4.5. 再进一步,自定义命令参数容器 0.5. 总结 环境变量GOPATH指向的是一个或多个工作区,每个工作区中都会有以代码包为基本组织形式的源码文件。 这里的源码文件分为三种: 命令源码文件 库源码文件 测试源码文件 它们有着不同的用途和编写规则。 0.1. 命令源码文件 独立的程序入口 属于main包,包含无参数无结果的main函数 可通过go run 命令运行,可接受命令行参数 main函数执行结束意味着当前程序运行结束 同一个代码包中不要放多个命令源码文件 命令源码文件与库源码文件也不要放在同一个代码包中 0.1.1. 构建 构建后生成可执行文件(executable file) 可在命令行中运行的文件 在Windows中就是扩展名为.exe的文件 在Linux中一般没有扩展名 生成位置在命令执行目录 0.1.2. 安装 安装后生成可执行文件 生成位置在当前工作区的bin子目录或GOPATH包含的目录 0.2. 库源码文件 用于放置可供其他代码使用的程序实体 0.2.1. 构建 作用在于检查和验证 构建后只生成临时文件 在操作系统的临时目录下 开发者一般不关心 0.2.2. 安装 安装后生成归档文件(archive file) 扩展名为.a的文件 即为静态链接库文件 生成位置在当前工作区的pkg子目录 0.3. 测试源码文件 0.3.1. 功能测试源码文件 测试函数名称(TestXXX) 测试函数签名(t *testing.T) 0.3.2. 性能(基准)测试源码文件 测试函数名称(BenchmarkXXX) 测试函数签名(b *testing.B) 0.3.3. 示例(样本)测试源码文件 测试函数名称(ExampleXXX) 测试函数签名(没有硬性要求) 测试函数期望输出 放置在函数末尾 用注释行表示 形如//Output:xxx 在学习Go语言的过程中,经常会编写可以直接运行的程序,这样的程序肯定会涉及命令源码文件的编写,命令源码文件可以很方便地使用go run 命令启动。 0.4. 命令源码文件的用途是什么?如何编写 命令源码文件是程序的运行入口,是每个可独立运行的程序必须拥有的。通过构建或安装,生成与其对应的可执行文件,后者一般会与该命令源码文件的直接父目录同名。 一个源码文件声明属于main包 包含一个无参数声明且无结果声明的main函数 那么它就是一个命令源码文件。如下所示: package main import "fmt" func main() { fmt.Println("Hello, world!") } 执行go run 命令后在标准输出中就能看到Hello, world!。 当需要模块化编程的时候,往往会将代码拆分到多个文件,甚至拆分到不同的代码包中。无论怎样,对于一个独立的程序来说,命令源码文件永远只有也只能有一个。如果有与命令源码文件同包的源码文件,那么它们也应该声明属于main包。 不论是什么操作系统,在命令行中执行的命令都是可以接收参数的。通过构建或安装命令源码文件,生成的可执行文件就可以被看作是命令,所以它也具备接收参数的能力。 0.4.1. 命令源码文件如何接收参数 package main import ( // 需在此处添加代码。[1] "fmt" "flag" ) var name string func init() { // 需在此处添加代码。[2] flag.StringVar(&name, "name", "everyone", "The greeting object.") } func main() { // 需在此处添加代码。[3] flag.Parse() fmt.Printf("Hello, %s!\n", name) } 在注释处编写代码,完成“根据运行程序给定的参数问候某人”的功能。 Go语言标准库中有一个代码包专门用于接收和解析命令参数。flag包。为了调用这个包中的程序实体来读取命令行参数,首先需要先将这个包导入。代码包的名字需要用英文半角的引号引起来。 人名都是由字符串组成,因此调用flag包中的StringVar函数。 函数flag.StringVar接收4个参数: 第一个参数用于存储该命令参数值的地址,即变量name的地址(&name) 第二个参数用于指定该命令参数的名称,即name 第三个参数用于指定在未追加该命令参数时的默认值,即everyone 第四个参数用于对该命令参数进行说明,这在打印命令说明时用到 在主函数中调用flag.Parse()函数,用于真正解析命令参数,并把它的值赋给相应的变量。对该函数的调用必须在所有命令参数存储载体的声明(即name变量)和设置(即flag.StringVar函数调用)之后,并且在读取任何命令参数值之前。所以在此处把Parse的调用放在main函数的第一行。 flag还有一个String函数,直接返回一个已经分配好的用于存储命令参数值的地址。 如果使用flag.String,进行如下修改: package main import ( // 需在此处添加代码。[1] "fmt" "flag" ) func init() { // 需在此处添加代码。[2] var name = flag.String("name", "everyone", "The greeting object.") } func main() { // 需在此处添加代码。[3] fmt.Printf("Hello, %s!\n", name) } 0.4.2. 如何在运行命令源文件的时候传入参数,如何查看参数的使用说明 假设上面的命令源文件名字为demo2.go。 运行如下命令,为参数name传值: go run demo2.go -name=Robert # 运行后,在标准输出中打印如下内容: Hello,Robert! 运行如下命令,查看命令源码文件的参数说明: go run demo2.go --help # 运行后,在标准输出中打印如下内容: Usage of /var/folders/ts/7lg_tl_x2gd_k1lm5g_48c7w0000gn/T/go-build155438482/b001/exe/demo2: -name string The greeting object. (default "everyone") exit status 2 输出中的/var/folders/ts/7lg_tl_x2gd_k1lm5g_48c7w0000gn/T/go-build155438482/b001/exe/demo2是go run 命令构建上述命令源码文件时临时生成的可执行文件的完整路径。 换个方式,先构建在执行: go build demo2.go ./demo2 --help # 运行后,在标准输出中打印如下内容: Usage of ./demo2: -name string The greeting object. (default "everyone") 0.4.3. 如何自定义命令源文件的参数使用说明 有多种方式可以实现,最简单的是对变量flag.Usage重新赋值。flag.Usage的类型是func(),即一种无参数声明且无结果声明的函数类型。 flag.Usage变量在声明时就已经被赋值了,所以在运行上述go run demo2.go --help时看到正确的结果。对flag.Usage的赋值必须在调用flag.Parse函数之前。 在demo2.go的基础上修改demo3.go,在main函数的开始处添加如下代码: flag.Usage = func(){ fmt.Fprint(os.Stderr, "Usage of %s:\n", "question") flag.PrintDefault() } 运行demo3.go: go run demo3.go --help # 在标准输出中打印如下内容 Usage of question: -name string The greeting object. (default "everyone") exit status 2 0.4.4. 深入一些 在调用flag包中的一些函数(StringVar,Parse等)时,实际上是在调用flag.CommandLine变量的对应方法。 flag.CommandLine相当于默认情况下的命令参数容器。通过对flag.CommandLine重新赋值,可以更深层次地定制当前命令源码文件的参数使用说明。 修改demo2.go中的init函数体: flag.CommandLine = flag.NewFlagSet("", flag.ExitOnError) flag.CommandLine.Usage = func(){ fmt.Fprint(os.Stderr, "Usage of %s:\n", "question") flag.PrintDefaults() } 再次执行go run demo2.go --help输入的结果与demo3.go相同,不过这种方式更加的命令,可以通过修改flag.NewFlagSet的第二个参数来实现不同输出效果的目的。如修改为flag.PanicOnError,这些都是flag包中的常量。 flag.ExitOnError:告诉命令参数容器,当命令后跟--help或者参数设置不正确的时候,在打印命令参数使用说明后以状态码2(表示用户错误的使用命令)退出当前程序。 flag.PanicOnError:与上面的区别在于,最后跑出一个运行时恐慌(panic)。 运行时恐慌是Go程序处理错误的方式。 0.4.5. 再进一步,自定义命令参数容器 不使用flag.CommandLine,自己创建一个私有的命令参数容器: var cmdLine = flag.NewFlagSet("question", flag.ExitOnError) 然后,把flag.StringVar的调用替换为cmdLine.StringVar调用,再把flag.Parse()替换为cmdLine.Parse(os.Args[1:])。 *flag.FlagSet类型的变量cmdLIne拥有很多有意思的方法。 这样的自定义更灵活,且不会影响到全局变量flag.CommandLine。 0.5. 总结 通过上述方法,可以使用Go语言编写命令,并且像其他操作系统中的命令那样被使用,也可以嵌入到各种脚本中。

03-库源码文件 阅读更多

0.1. 如何把命令源码文件中的代码拆分到其他库源码文件 0.2. 代码包声明的基本原则 0.2.1. 程序实体的访问权限 0.2.2. 其他访问权限规则 库源码文件是不能被直接运行的源码文件,它仅用于存放程序实体,这些程序实体可以被其他代码使用(遵从Go语言规范)。 其他代码的位置: 与被使用的程序实体在同一个源码文件内, 在其他源码文件内, 在其他代码包中。 在Go语言中,程序实体是变量、常量、函数、结构体和接口的统称。我们总是先声明(或定义)程序实体,然后再去使用。程序实体的名字被统称为标识符(可以是任何Unicode编码可以表示的字母字符、数字以及下划线,但是首字母不能为数字)。从规则上来看,可以使用中文作为变量的名字。 0.1. 如何把命令源码文件中的代码拆分到其他库源码文件 下面有样例代码demo4.go: package main import ( "flag" ) var name string func init() { flag.StringVar(&name, "name", "everyone", "The greeting object.") } func main() { flag.Parse() hello(name) } 函数hello()被声明在另外一个源码文件中: // 需在此处添加代码。[1] import "fmt" func hello(name string) { fmt.Printf("Hello, %s!\n", name) } 注释处需要填入package main的名字。因为同一个目录下的源码文件需要被声明属于同一个代码包。如果该目录下有一个命令源码文件,那么为了让同一个目录下的文件都通过编译,其他源码文件应该被声明为属于main包。 0.2. 代码包声明的基本原则 同目录下的源码文件的代码包声明语句必须要一致。即它们要同属于一个代码包。这对所有源码文件都适用。如果目录中有命令源码文件,那么其他种类的源码文件也应该声明属于main包,这是可以成功构建并允许的前提。 源码文件声明的代码包的名称可以与其所在的目录的名称不同。在针对代码包进行构建时,生成的结果文件的主名称与其父母的名称一致。 导入路径:源码文件所在的目录相对与src目录的相对路径就是它的代码包导入路径, 限定符:实际使用其程序实体时给定的限定符要与它声明所属的代码包名称对应 通常情况下,总是让声明的包名与其父目录的名称一致。 0.2.1. 程序实体的访问权限 名称的首字母大写的程序实体才可以被当前包外的代码引用,否则它就只能被当前包内的代码引用。 0.2.2. 其他访问权限规则 Go1.5之后的版本,通过创建internal代码包让一些程序实体仅仅能被当前模块中的其他代码引用。这是Go语言的第三种访问权限:模块级私有。 internal代码包中声明的公开程序实体仅能被该代码的直接父包及其子包中的代码引用。引用之前先要导入这个internal包,对于其他代码包,导入行为是非法的,不能通过编译。

04-程序实体 阅读更多

0.1. 变量 0.1.1. 类型推断的好处 0.1.2. 变量的重声明 0.1.3. 代码块 0.1.4. 判断变量类型 0.1.5. 别名类型&潜在类型 0.1.5.1. 别名类型 0.1.5.2. 潜在类型 Go语言中的程序实体包括: 变量 常量 函数 结构体 接口 Go语言是静态类型的编程语言,需要在声明变量和常量的时候,指定它们的类型或给予足够的信息,让Go语言能够推导出它们的类型。 变量的合法类型: Go语言预定义的类型 自定义的函数、结构体、接口 常量的合法类型: Go语言预定义的基本类型 0.1. 变量 // 变量声明方式的不同方式 var name string var name="string" // 类型推断 【编程语言在编译期自动解释表达式类型的能力】 // 表达式类型是对表达式求值后得到结果的类型 // 类型推断只能用于变量或常量的初始化 name := "string" // 短变量声明 【只能在函数体内部使用】,也属于类型推断 0.1.1. 类型推断的好处 类型推断在编译期执行,对程序的运行效率没有影响。 代码重构:通常把不改变某个程序与外界的任何交互方式和规则,而只改变内部实现的代码修改方式,重构对象(代码、函数、模块、系统),可以随意修改被重构部分的代码,而不影响调用它的部分。 不显式地指定变量或常量的类型,使得它可以被赋予任何类型的值,变量的类型在初始化的时候,由程序动态确定。 Go是静态类型语言,所以一点初始化变量是确定了类型,之后就不能再改变。 0.1.2. 变量的重声明 使用短变量声明,可以对统一代码块中的变量进行重声明。 代码块的含义: 全域代码块 代码包 源文件 函数 if、for、switch、select、case语句 空代码块 变量重声明的前提: 变量的类型在初始化时已经确定,重声明的类型必须与元类型相同,否则产生编译错误 变量的重声明只能发生在某一个代码块 变量的重声明只有使用短变量声明是才会发生,否则无法通过编译 声明并赋值的变量必须是多个,并且其中至少有一个是新的变量 使用短变量声明时可不用判断在意被赋值的多个变量中是否包含旧变量。 0.1.3. 代码块 作用域: 包级私有:代码包,代码块 模块级私有:代码包,代码块 公开:全域代码块 一个程序实体的作用域总是会被限制在某个代码块中,而这个作用域最大的用处,就是对程序实体的访问权限的控制。 代码引用变量的时候,总是会优先查找当前代码块中的变量,不包含子代码块。 如果当前代码块中没有声明以此为名的变量,那么程序沿着代码块的嵌套关系,从直接包含的当前块开始,一层一层地查找。 不会查找代码导入的其他包,除非代码包导入语句如下import . XXX,这表示让XXX包中公开的程序实体被视为当前源码文件中的实体。 不同代码块中的可重名变量与变量重声明中的变量的区别: | 差异 | 变量重声明 | 可重名变量 | | -------- | -------------------------- | -------------------------- | | 代码块 | 统一代码块 | 不同代码块 | | 数量 | 只有一个变量 | 多个变量 | | 变量类型 | 类型不可变,与初始化时相同 | 类型任意 | | 变量屏蔽 | 无 | 代码块嵌套,则出现变量屏蔽 | 0.1.4. 判断变量类型 类型断言表达式:x.(T),x代表要判断类型的值 类型转换表达书:T(x),x源值,x的类型是源类型,T目标类型 在Go语言中Interface{}代表空接口,任何类型都是它的实现类型。Interface(x),将x转换为空接口的值。 的含义,空代码块或者空数据类型。 value,ok := interface{}(container).([]string) //value:类型转换后的值,ok:断言是否成功 // value,ok := interface{}(<变量>).(<变量类型>) // interface{}(<变量>),将变量的值转换为空接口的值 // .(<变量类型>),判断前者类型是否为变量类型 类型转换规则注意事项: 类型转换表达式T(x)中,x可以使变量,代表值的字面量,结果只有一个值的表达式 类型转换的合法性,大范围转为小范围会被截取 整数转string,如果对应的整数无有效的unicode代码点,转换后变成�,在Unicode中专门替换未知的,不被认可的,无法展示的字符 string类型与切片类型之间的互转: string->[]byte:UTF-8字符串被拆分为ASCII字节 string->[]rune:UTF-8字符串被拆分为Unicode字符 0.1.5. 别名类型&潜在类型 关键字type声明自定义的各种类型,这些类型必须在Go语言基本类型和高级类型之内。 0.1.5.1. 别名类型 type MyString = string // MyString是string的别名类型,只是名称有差别,只要用于代码重构 byte是uint8的别名类型 rune是int32的别名类型 0.1.5.2. 潜在类型 type Mystring2 string // 对类型在定义 // Mystring2是一个新的类型 // 此处string成为潜在类型 潜在类型相同的不同类型的值之间可以进行类型转换 潜在类型相同的不同类型的值之间不能判等

05-数组和切片 阅读更多

0.1. 引用类型与值类型 0.2. 内建函数 0.3. 切片与底层数组 0.3.1. 切片扩容 0.3.2. 切片底层数组被替换的情况 0.4. 可寻址与不可寻址 0.4.1. 不可寻址 0.4.2. 方法调用 并发访问切片 加锁 channl串行化 共同点:都属于集合类的类型,可以存储某一种类型的值;应用索引表达式得到值,应用切片表达式得到新切片 不同点:数组的值长度固定,切片的值可变长 数组的长度必须在声明时给定,且不可变,它是数组类型的一部分。 切片的类型字面量中,只有元素类型,长度随着元素数量增长而增长,但不会减小。 切片可以看成是对数组的一层简单封装,在每个切片的底层数据结构中,一定会包含一个数组。 数组:是切片的底层数组 切片:是对数组的某个连续片段的引用,切片的容量代表了其可见底层数组的长度,切片的底层数组长度不可变 0.1. 引用类型与值类型 引用类型: 切片类型(切片是对数组某个连续片段的引用)【相对于slice底层的数组而言,其实slice是一个结构体类型(也就是值类型)】 字典类型 通道类型 函数类型 值类型: 基础数据类型 结构体类型 数组类型 切片类型【相对于slice底层的数组而言,其实slice是一个结构体类型(也就是值类型)】 Go语言中不存在“传值或传引用”的问题,在Go语言中只要看被传递的值的类型,如果被传递的值是引用类型,那就是“传引用”,如果被传递的值是值类型,那就是“传值”。从传递成本的角度,引用类型的值比值类型的值成本低很多。 // 切片类型 type slice struct { array unsafe.Pointer len int cap int } // 直接通过索引操作时,是对切片底层的数组进行操作,通过array指针实现 // 切片作为参数时,传递的是切片的副本 0.2. 内建函数 len():得到数组或切片的长度 cap():得到数组或切片的容量 make(): 创建切片、字典、通道等 append(): 切片中追加值 数组容量永远和长度相等,且不可变。 // usage of make() // 返回值而不是指针 make(types interface{},length int,capacity int) // types:要创建的类型 // length:该类型的长度 // capacity:该类型的容量 0.3. 切片与底层数组 // make函数或切片值字面量初始化时,切片最左边与底层数组最左边对应 slice1 := make([]int,3,5) sliec2 := []int{1,2,3,4,5,6,7,8} // 切片表达式创建切片时,切片最左边与底层数组索引对应,slice3的最左边对应slice2的索引3 slice3 := slice2[3:6] // slice3的容量为可见底层数组的容量,即底层数组减去切片起始索引值 // slice3向右扩展至最大 slice3 = slice3[0:cap(slice3)] 切片表达式是一个开区间,得到的新切片的容量和长度为区间的差值。新切片的起始值为原切片或数组对应的索引值。 切片无法向左扩展,但是可以向右扩展。 0.3.1. 切片扩容 当切片无法容纳更多元素时,Go用语言会进行扩容,不会改变原切片,而是创建一个容量更大的新切片,将原来的元素和新的元素一起拷贝到新的切片中。 一般情况扩容为原来的2倍,当原切片的长度大于等于1024后,一次增长1.25倍的方式逐渐扩容。 0.3.2. 切片底层数组被替换的情况 切片在扩容时,创建了新的切片和新的底层数组,原来的切片和底层数组没有任何改动。 在底层数组容量(即切片容量)足够的情况下append()函数返回的是指向原底层数组的切片 在底层数组容量(即切片容量)不够的情况下append()函数返回的是指向新底层数组的新切片 0.4. 可寻址与不可寻址 package main import ( "crypto/sha1" "fmt" ) func main() { input := []byte("Hello, playground") hash := sha1.Sum(input)[:5] // fmt.Println(hash) } // output ./test.go:8:28: invalid operation sha1.Sum(input)[:5] (slice of unaddressable value) 看一下crypto/sha1库: // The size of a SHA-1 checksum in bytes. const Size = 20 // The blocksize of SHA-1 in bytes. const BlockSize = 64 func Sum(data []byte) [Size]byte { var d digest d.Reset() d.Write(data) return d.checkSum() } sha1.Sum()返回一个长度是20的数组,而不是切片(如果是切片不会报错)。 Go是返回数值的,所以这里是20字节的数组,而不是指向它的指针。 0.4.1. 不可寻址 大多数匿名值都不可寻址(复合字面值是一个例外)。 在上面的代码中,sha1.Sum() 的返回值是匿名的,因为我们立即对其进行了切片操作。如果我们将它存在变量中,并因此使其变为非匿名,就是可寻址的,则该代码不会报错,如下所示。 tmp := sha1.Sum(input) hash := tmp[:5] 因为对数组进行切片操作要求该数组是可寻址的,sha1.Sum() 返回的匿名数组是不可寻址的,因此对其进行切片会被编译器拒绝。 如果在这里允许对不可寻址的匿名值进行切片操作,那么 Go 要默默地实现堆存储以容纳 sha1.Sum() 的返回值(然后将该值复制到另一个值),该返回值将一直存在直到那个切片被回收。 注意:Go 语言规范中的许多内容要求或仅对可寻址的值适用。例如,大多数赋值操作需要可寻址性。 0.4.2. 方法调用 假设有一个类型 T,并且在 *T 上定义了一些方法,例如 *T.Op()。就像 Go 允许在不取消引用指针的情况下进行字段引用一样,可以在非指针值上调用指针方法: type T struct{ } func (t *T)Op(){ } // afunc() 返回一个 T func aFunc() T{ return T } var x T x.Op() // 这是 (&x).Op() 的简便写法 // 此简便写法需要获取地址,因此需要可寻址性,以下操作报错 aFunc().Op() // 但是这个可以运行 var x T = aFunc() x.Op() 并发访问切片 由于 slice/map 是引用类型,golang 函数是传值调用,所用参数副本依然是原来的 slice/map, 并发访问同一个资源会导致竞态条件。 package main import ( "fmt" "sync" ) func main() { var ( slc []int n = 1000 wg sync.WaitGroup ) wg.Add(n) for i := 0; i < n; i++ { go func() { slc = append(slc, 1) wg.Done() }() } wg.Wait() fmt.Println("len->", len(slc)) fmt.Println("cap->", cap(slc)) } // output len-> 997 cap-> 1024 真实的输出并没有达到我们的预期,len(slice) < n。 slice是对数组一个连续片段的引用,当 slice 长度增加的时候,底层的数组会被换掉。当在换底层数组之前,切片同时被多个 goroutine 拿到,并执行 append 操作。那么很多 goroutine 的 append 结果会被覆盖,导致 n 个 gouroutine append 后,长度小于n。 go 1.9 增加sync.map实现并发安全,slice咋整? 加锁 优点是比较简单,适合对性能要求不高的场景。 package main import ( "fmt" "sync" ) func main() { var ( slc []int n = 1000 wg sync.WaitGroup lock sync.Mutex ) wg.Add(n) for i := 0; i < n; i++ { go func() { lock.Lock() defer lock.Unlock() slc = append(slc, 1) wg.Done() }() } wg.Wait() fmt.Println("len->", len(slc)) fmt.Println("cap->", cap(slc)) } channl串行化 实现相对复杂,优点是性能很好,利用了channel的优势。 package main import ( "fmt" "sync" ) type Service struct { ch chan int // 同步channel data []int // 存储数据的slice } func (s *Service) Schedule() { for i := range s.ch { s.data = append(s.data, i) } } func (s *Service) Close() { close(s.ch) } func (s *Service) AddData(v int) { s.ch <- v } func NewScheduler(size int, done func()) *Service { s := &Service{ ch: make(chan int, size), data: make([]int, 0), } go func() { s.Schedule() done() }() return s } func main() { var ( n = 1000 wg sync.WaitGroup ) c := make(chan struct{}) s := NewScheduler(n, func() { c <- struct{}{} }) wg.Add(n) for i := 0; i < n; i++ { go func(v int) { defer wg.Done() s.AddData(v) }(i) } wg.Wait() s.Close() <-c fmt.Println("len->", len(s.data)) fmt.Println("cap->", cap(s.data)) }

06-Container 阅读更多

0.1. heap 0.2. list 0.2.1. 开箱即用 0.2.2. 延迟初始化 0.3. ring与list的区别 container包中的容器都不是线程安全的。 0.1. heap heap 是一个堆的实现。一个堆正常保证了获取/弹出最大(最小)元素的时间为o(logn)、插入元素的时间为 o(logn)。 包中有示例。 堆实现接口如下: // src/container/heap.go type Interface interface { sort.Interface Push(x interface{}) // add x as element Len() Pop() interface{} // remove and return element Len() - 1. } heap 是基于 sort.Interface 实现的: // src/sort/ type Interface interface { Len() int Less(i, j int) bool Swap(i, j int) } 因此,如果要使用官方提供的 heap,需要我们实现如下几个接口: Len() int {} // 获取元素个数 Less(i, j int) bool {} // 比较方法 Swap(i, j int) // 元素交换方法 Push(x interface{}){} // 在末尾追加元素 Pop() interface{} // 返回末尾元素 然后在使用时,我们可以使用如下几种方法: // 初始化一个堆 func Init(h Interface){} // push一个元素倒堆中 func Push(h Interface, x interface{}){} // pop 堆顶元素 func Pop(h Interface) interface{} {} // 删除堆中某个元素,时间复杂度 log n func Remove(h Interface, i int) interface{} {} // 调整i位置的元素位置(位置I的数据变更后) func Fix(h Interface, i int){} 0.2. list Go语言的链表实现在标准库的container/list代码包中。代码包中有两个公开的程序实体: List:双向链表 Element:链表中元素的结构 List的方法: MoveBefore & MoveAfter:把给定元素移动到另一个元素的前面或后面 MoveToFront & MoveToBack:把给定元素移动到链表的最前端和最后端 这些给定元素都是*Element类型,*Element的值就是元素的指针。 func (l *List) MoveBefore(e, mark *Element) func (l *List) MoveAfter(e, mark *Element) func (l *List) MoveToFront(e *Element) func (l *List) MoveToBack(e *Element) 在List包含的方法中,用于插入新元素的那些方法都只接受Interface类型的值。这些方法在内部会使用Element值,包装接收到的新元素。这样子为了避免链表的内部关联遭到外界破坏。 func (l *List) Front() *Element //返回链表最前端元素的指针 func (l *List) Back() *Element //返回链表最后段元素的指针 func (l *List) InsertBefore(v interface{}, mark *Element) *Element //在链表指定元素前插入元素并返回插入元素的指针 func (l *List) InsertAfter(v interface{}, mark *Element) *Element //在链表指定元素后插入元素并返回插入元素的指针 func (l *List) PushFront(v interface{}) *Element //将指定元素插入到链表头 func (l *List) PushBack(v interface{}) *Element //将指定元素插入到链表尾 0.2.1. 开箱即用 List和Element都是结构体类型,结构体类型的特点是它们的零值都拥有特定结构,但是没有任何定制化内容的值,值中的字段都被赋予各自类型的零值。 只做声明却没有初始化的变量被赋予的缺省值就是零值,每个类型的零值都会依据该类型的特性而被设定。 var l list.List声明的变量l的值是一个长度为0的链表,链表持有的根元素也是一个空壳,其中包含缺省的内容。这样的链表可以开箱即用的原因在于“延迟初始化”机制。 0.2.2. 延迟初始化 优点: 把初始化操作延后,仅在实际需要的时候才进行,延迟初始化的优点在于“延后”,它可以分散初始化操作带来的计算量和存储空间消耗。 如果需要集中声明非常多的大容量切片,那么CPU和内存的使用量肯定会激增,并且只要设法让其中的切片和底层数组被回收,内存使用量才会有所下降。如果数组可以被延迟初始化,那么CPU和内存的压力就被分散到实际使用它们的时候,这些数组被实际使用的时间越分散,延迟初始化的优势就越明显。 缺点: 延迟初始化的缺点也在于“延后”,如果在调用链表的每个方法的时候,都需要先去判断链表是否已经被初始化,这也是计算量上的浪费,这些方法被非常频繁地调用的情况下,这种浪费的影响就开始明显,程序的性能会降低。 解决方案: 在链表的视线中,一些方法无需对是否初始化做判断,如Front()和Back()方法,一旦发现链表的长度为0,直接返回nil 在插入、删除或移动元素的方法中,只要判断传入的元素中指向所属链表的指针,是否与当前链表的指针相等就可以 PushFront()、PushBack()、PushBackList()、PushFrontList()方法总是会先判断链表的状态,并在必要时进行延迟初始化。在向新链表中添加新元素时,肯定会调用这四个方法之一。 0.3. ring与list的区别 container/ring包中的Ring类型实现的是一个循环链表(环)。 List在内部就是一个循环链表,它的根元素永远不会持有任何实际的元素值,而该元素的存在是为了连接这个循环链表的收尾两端。List的零值是已给只包含根元素,不包含任何实际元素值的空链表。 差异 Ring List 表示方式、结构复杂度 Ring类型的数据结构仅由它自身即可代表 List类型需要它及Element类型联合表示 表述维度 Ring类型的值,严格来说只代表起所属的循环链表中的一个元素 而List类型的值则代表一个完整的链表 New函数功能 创建并初始化Ring,可以指定包含的元素数量,创建后长度不可变 创建并初始化List不能指定包含的元素数量 初始化 var r ring.Ring声明的r是一个长度为1的循环链表 var l list.List声明的l是一个长度为0的链表 时间复杂度 Ring的len方法时间复杂度o(N) List的len方法时间复杂度o(1) List中的根元素不会持有实际元素值,计算长度时不会包含它。

07-字典 阅读更多

0.1. 键类型约束 0.2. 键类型的优先选择 0.2.1. 哈希算法 map中存储的不是单一值的集合,而是键值对的集合。 一个键和一个值,分别代表了一个从属于某一类型的独立值,把它们两个捆绑在一起就是一个键值对。 0.1. 键类型约束 Go语言的字典类型其实是一个哈希表的特定实现,在这个实现中,键的类型是受限的,值可以是任意类型。 把键理解为元素的一个索引,在哈希表中通过键查找与它成对的那个元素。键与值的对应关系称为映射,哈希表的映射过程就存在于对键值对的增删改查操作中。 映射过程的第一步就是键值转换为哈希值:Go中的每个键都是有它的哈希值代表,字典不会独立存储任何键值,但会独立存储它们的哈希值。 Go语言字典的键类型不支持函数类型、字典类型和切片类型。 Go语言规范规定,在键类型的值之间,必须可以使用操作符==和!=,也就是必须支持判等操作,上述三种类型的值不支持判等操作,所以字典的键类型不能是这些类型。 如果键的类型是接口类型,那么键值的实际类型也不能是上述三种类型,否则程序运行中会引发panic。 如果键的类型是数组类型,那么确保该类型的元素类型不是上述三种类型。 如果键的类型是结构体,那么保证其中字段类型的合法性。 因为”哈希碰撞“的存在,Go语言首先用键的哈希值去进行比对,如果键的哈希值相同,在用键本身去比对,如果键类型的值之间无法判等,那么这个映射过程就无法继续进行。 0.2. 键类型的优先选择 在映射的过程中,有两个重要且耗时的操作: 把键值转换为哈希值 把要查找的键值与哈希桶中的键值做对比 但从性能的角度,求哈希和判等操作的速度越快,对应的类型就越适合作为键类型。 Go语言中所有的基本类型、指针类型、数组类型、结构体类型和接口类型都要一套各自的算法,其中包含哈希和判等。 0.2.1. 哈希算法 基本类型: 类型的宽度越小的类型求哈希的速度越快。 类型的宽度是指它的单个值需要占用的字节数。 在基本类型中,有限选择数值类型和指针类型,通常情况下类型的宽度越小越好。 高级类型: 对数组类型值的求哈希实际上是依次求得它的每个元素的哈希值并进行合并,所以速度取决于它的元素类型以及它的长度。 对于结构体类型的值求哈希实际上就是它的所有字段求哈希并进行合并,关键在于各个字段的类型以及字段的数量。 对于接口类型,具体的哈希算法由值的实际类型决定。 不建议使用高级类型作为字典的键,不仅因为求哈希和判等速度慢,而且它们中的值存在变数。 除了添加键值对,在一个值为nil的字典上做任何操作都不会引起错误。

08-通道 阅读更多

0.1. 长时间阻塞的发送和接收操作 0.1.1. 缓冲通道 0.1.2. 非缓冲通道 0.1.3. 错误使用通道 0.2. 引起panic的发送和接收操作 0.3. 单向通道 0.3.1. 单向通道的价值 0.4. select语句 0.4.1. 分支选择规则 0.4.2. 四大用法 0.5. 并发设计模式 0.5.1. Barrier模式 0.5.1.1. 使用场景 0.5.2. Future模式 0.5.2.1. 使用场景 0.5.3. Pipeline模式 0.5.3.1. 使用场景 Worker Pool模式 使用场景 Pub/Sub模式 使用场景 注意事项 Don't communicate by sharing memory;share memory bu communicating. 不要通过共享内存来通信,而应该通过通信来共享内存。 通道与goroutine共同代表Go语言独有的并发编程模式和编程哲学,利用通道在多个goroutine之间传递数据。 通道类型的值,本身就是并发安全的。这是Go语言自带的唯一一个可以满足并发安全性的类型。 在声明并初始化一个通道的时候,需要使用Go内建函数make(),传给这个函数的第一个参数应该是代表了通道的具体类型的类型字面量,第二个参数是一个int类型的值,不能小于0,表示通道的容量(该参数可选)。 当容量 = 0 ,表示非缓冲通道 当容量 > 0 ,表示缓冲通道 非缓冲通道和缓冲通道有不同的数据传递方式。 声明一个通道类型变量的时候,首先要确定该通道类型的元素类型,这决定了通过这个通道传递声明类型的数据。 chan int // chan 表示通道类型的关键字 // int 说明该通道类型的元素类型 ch1 := make(chan int,3) 一个通道相当一个FIFO队列,通道中各个元素严格按照发送顺序排列,元素值的发送和接收都用到操作符<-,称为接送操作符,该符号形象的表示了元素值的传输方向。 对发送与接收操作的基本特性: 对同一个通道,发送操作之间是互斥的,接收操作之间也是互斥的: 在同一时刻,Go语言运行时系统只会执行对同一个通道的任意个发送操作中的某一个。直到这个元素值被完全复制进该通道之后,其他针对该通道的发送操作才可能被执行。接收操作也是一样的,即使操作是并发执行的也是如此。 对于通道内的同一个元素值,发送操作和接收操作之间也是互斥的。即使一个正在被复制进通道但还未复制完成的元素值,也绝不会被想接收它的一方看到和取走。 元素值从外界进入通道时是被复制,即进入通道的并不是接收操作符右边的那个元素值,而是它的副本。 元素从通道进入外界时会被移动: 生成正在通道中的这个元素值的副本,并准备给到接收方 删除在通道中的这个元素值 发送操作和接收操作中对元素值的处理都是不可分割的: 不可分割表示处理通道中元素的操作是一个原子操作: 发送操作要么没复制值,要么已经复制完毕 接收操作在准备好元素值副本之后,一定会删除掉通道中的原值,绝不会出现有残留的情况 发送操作和接收操作在完全完成之前会被阻塞 发送操作: 复制元素值 放置副本到通道内部 接收操作: 复制通道内的元素值 放置副本到接收方 删除原值 在所有步骤完全完成前,发起该操作的代码会一直阻塞,直到该代码所在goroutine收到了运行时系统的通知并重新获得运行机会为止。 如此阻塞代码就是为了实现操作的互斥和元素值的完整。 0.1. 长时间阻塞的发送和接收操作 0.1.1. 缓冲通道 如果通道已满,对它的所有发送操作都会被阻塞,直到通道中有元素值被接收走,此时通道会优先通知最早因此而等待的那个发送操作所在的goroutine,然后再次执行发送操作。 由于发送操作在这种情况下被阻塞后,它们所在的goroutine会顺序地进入通道内部的发送等待队列,所以通知的顺序是公平的。 如果通道已空,对它的所有接收操作都会被阻塞,直到通道中有新的元素出现,此时通道会优先通知最早等待的那个接收操作所在的goroutine,并使它再次执行接收操作。 因此而等待的所有接收操作所在的goroutine都会按照先后顺序被放入通道内部的接收等待队列。 缓冲通道作为收发双方的中间件,元素值先从发送方复制到缓冲通道,之后再由缓冲通道复制给接收方。当发送操作在执行的时候发现空的通道中,正好有等待的接收操作,那么它会直接把元素值复制给接收方。 0.1.2. 非缓冲通道 无论是发送操作还是接收操作,一开始执行就会被阻塞,直到配对的操作也开始执行,才会继续传递。 非缓冲通道是在用同步的方式传递数据,只有收发双方对接上了,数据才会被传递。 数据直接从发送方复制到接收方,中间并没有非缓冲通道做中转,相比之下,缓冲通道则在用异步的方式传递数据。 0.1.3. 错误使用通道 对值为nil的通道,不论它的具体类型是什么,对它的发送和接收操作都会永久地处于阻塞状态。它们所属的goroutine中的任何代码都不再会被执行。 通道类型是引用,所以它的零值就是nil,只声明该类型的变量但没有用make()函数对它初始化时,该变量的值就是nil。 0.2. 引起panic的发送和接收操作 对已关闭的通道进行发送操作(接收操作可以感知到通道已经关闭,并安全退出) 关闭已经关闭的通道 接收操作返回两个值: 元素值 接收操作成功与否 如果通道关闭时,里面有元素值未取出,接收操作会把通道中的值按顺序取值之后,在返回通道已关闭的false判断。因此,通过接收操作的返回值的第二个值来判断通道是否关闭有延迟。 除非有特殊保证,否则让发送方关闭通道,而不是接收方。 0.3. 单向通道 通常说的通道都是双向通道,可以发也可以收。 单向通道:只能发或者之只能收。 一个通道是双向还是单向,由类型字面量体现。 var uselessChan = make(chan<- int,1) // 发送通道,只能发(往通道中发送) uselessChan = make(<-chan int,1) // 接收通道,只能收(从通道中接收) 站在操作通道的代码的角度,看单向通道是发送通道还是接收通道。 0.3.1. 单向通道的价值 单向通道最主要的用途是约束其他代码的行为。 例子: func SendInt(ch chan<- int) { ch <- rand.Intn(1000) } // SendInt函数,只能接受一个发送通道,函数中的代码只能向通道中发送元素值,而不能从通道中接收元素值 在实际场景中,约束一般出现在接口类型声明中的某个方法定义上,或者,声明函数类型时,如果使用单向通道,相当于约束所有实现这个函数类型的函数。在编写模板代码或可扩展的程序库是很有用。 type Notifier interface { SendInt(ch chan<- int) } // 定义SendInt方法,参数是一个发送通道 // 该接口的所实现类型中的SendInt方法都受到限制 // 在调用SendInt函数时,只需要将一个双向通道作为参数传递给它,Go语言会自定把它转换为所需的单向通道 在接口类型声明的花括号中,每一行代表一个方法的定义。 接口中方法定义与函数声明很类似,只包含方法名,参数列表和结果列表。一个类型如果想要成为一个接口的实现类型,必须实现接口中定义的所有方法。因此某个方法中定义了单向通道,那么相当于对它的所有实现做出约束。 func getIntChan() <-chan int { num := 5 ch := make(chan int, num) for i := 0; i < num; i++ { ch <- i } close(ch) return ch } // 在函数的结果类别中使用单向通道 // 得到该通道的程序,只能从通道中接收元素值,这是对函数调用方法的一种约束 intChan2 := getIntChan() for elem := range intChan2 { fmt.Printf("The element in intChan2: %v\n", elem) } // for语句循环的从单向通道中取出元素值 对上述for语句的解释: for语句会不断尝试从initChan2取出元素值,即使通道被关闭,也会取出所有剩余的元素值之后再结束执行 单向通道中没有元素值时,代码会被阻塞在for关键字那一行 initChan2的值为nil,代码会被永远阻塞在for关键字那一行 上述三点是带range子句的for循环与通道的联系,Go还有专门操作通道的select语句。 0.4. select语句 select语句只能与通道联用,一般由若干个分支组成,每次执行select语句的时候,只有一个分支中的代码会被执行。 select语句的分支: 候选分支:以关键字case开头,后面是一个case表达式和一个冒号,从下一行开始写入,分支被选中时需要执行的语句 默认分支:default case,当且仅当没有候选分支被选中时,它才会被执行,default开头后面直接是冒号,从下一行开始写入要执行的语句 select语句是专门为通道而设计的,每个case表达式中只能包含操作通道的表达式,如接收表达式。 例子: // 准备好几个通道。 intChannels := [3]chan int{ make(chan int, 1), make(chan int, 1), make(chan int, 1), } // 随机选择一个通道,并向它发送元素值。 index := rand.Intn(3) fmt.Printf("The index: %d\n", index) intChannels[index] <- index // 哪一个通道中有可取的元素值,哪个对应的分支就会被执行。 select { case <-intChannels[0]: fmt.Println("The first candidate case is selected.") case <-intChannels[1]: fmt.Println("The second candidate case is selected.") case elem := <-intChannels[2]: fmt.Printf("The third candidate case is selected, the element is %d.\n", elem) default: fmt.Println("No candidate case is selected!") } select语句的注意点: 设置默认分支后,无论涉及通道操作的表达式是否有阻塞,select语句都不会被阻塞。 如果没有默认分支,一旦所有case表达式都没有满足求值条件,那么select语句就会被阻塞,直到至少有一个case表达式满足条件为止。 当通道关闭后,会从通道中接收到其元素类型的零值,所以需要接收表达式的第二个结果值来判断通道是否关闭。一旦发现某个通道关闭了,应该及时屏蔽对应的分支或采取其他措施。 select语句只能对其中的每一个case表达式各求值一次。如果连续或定时地操作其中的通道,就需要通过for语句中嵌入select语句的方式实现。简单地在select语句的分支中使用break语句,只能结束当前的select语句的执行,而并不会对外层的for语句产生作用。这种错误的用法可能会让这个for语句无休止的运行下去。如下面的例子。 intChan := make(chan int, 1) // 一秒后关闭通道。 time.AfterFunc(time.Second, func() { close(intChan) }) select { case _, ok := <-intChan: if !ok { fmt.Println("The candidate case is closed.") break } fmt.Println("The candidate case is selected.") } 0.4.1. 分支选择规则 每一个case表达式,至少有一个发送或接收操作,也可以包含其他的表达式。多个表达式从左到右顺序被求值 select语句包含的候选分支中的case表达式会在该语句执行时先被求值,求值顺序从代码编写的顺序从上往下,所有分支都会被求值,从上到下,从左往右 case表达式中的发送或接收操作处于阻塞状态时,该case表达式的求值就不成功,即候选分支不满足条件 只有当所有case表达式都被求值完成后,才开始选择候选分支。只会挑选满足条件的候选分支执行 所有候选分支都不满足条件,选择默认分支 没有默认分支,select语句处于阻塞状态,直到至少有一个候选分支满足条件为止 如果同时有多个候选分支满足条件,用伪随机算法在候选分支中选择一个,然后执行 一个select语句只有一个默认分支,并且默认分支只在无候选分支可选的时候才会执行,与编写位置无关 select语句的每次执行,包括case表达式求值和分支选择都是独立的,它的执行是否并发安全,要看其中的case表达式以及分支中是否包含并发不安全的代码 0.4.2. 四大用法 满足条件的case是随机选择的 增加超时机制time.AfterFunc() 检查channel是否已满 for+select,要在 select 区间直接结束掉 for 循环,只能使用 break <标识> 来结束(标识定义在for循环之外) 0.5. 并发设计模式 下面每一种模式的设计都依赖于 channel。 0.5.1. Barrier模式 barrier 屏障模式故名思义就是一种屏障,用来阻塞直到聚合所有 goroutine 返回结果。可以使用 channel 来实现。 0.5.1.1. 使用场景 多个网络请求并发,聚合结果 粗粒度任务拆分并发执行,聚合结果 /* * Barrier */ type barrierResp struct { Err error Resp string Status int } // 构造请求 func makeRequest(out chan<- barrierResp, url string) { res := barrierResp{} client := http.Client{ Timeout: time.Duration(2*time.Microsecond), } resp, err := client.Get(url) if resp != nil { res.Status = resp.StatusCode } if err != nil { res.Err = err out <- res return } byt, err := ioutil.ReadAll(resp.Body) defer resp.Body.Close() if err != nil { res.Err = err out <- res return } res.Resp = string(byt) out <- res } // 合并结果 func barrier(endpoints ...string) { requestNumber := len(endpoints) in := make(chan barrierResp, requestNumber) response := make([]barrierResp, requestNumber) defer close(in) for _, endpoints := range endpoints { go makeRequest(in, endpoints) } var hasError bool for i := 0; i < requestNumber; i++ { resp := <-in if resp.Err != nil { fmt.Println("ERROR: ", resp.Err, resp.Status) hasError = true } response[i] = resp } if !hasError { for _, resp := range response { fmt.Println(resp.Status) } } } func main() { barrier([]string{"https://www.baidu.com", "http://www.sina.com", "https://segmentfault.com/"}...) } Barrier 模式也可以使用 golang.org/x/sync/errgroup 扩展库来实现,这样更加简单明了。这个包有点类似于 sync.WaitGroup,但是区别是当其中一个任务发生错误时,可以返回该错误。 func barrier(endpoints ...string) { var g errgroup.Group var mu sync.Mutex response := make([]barrierResp, len(endpoints)) for i, endpoint := range endpoints { i, endpoint := i, endpoint // create locals for closure below g.Go(func() error { res := barrierResp{} resp, err := http.Get(endpoint) if err != nil { return err } byt, err := ioutil.ReadAll(resp.Body) defer resp.Body.Close() if err != nil { return err } res.Resp = string(byt) mu.Lock() response[i] = res mu.Unlock() return err }) } if err := g.Wait(); err != nil { fmt.Println(err) } for _, resp := range response { fmt.Println(resp.Status) } } 0.5.2. Future模式 常用在异步处理也称为 Promise 模式,采用一种 fire-and-forget 的方式,是指主 goroutine 不等子 goroutine 执行完就直接返回了,然后等到未来执行完的时候再去取结果。在 Go 中由于 goroutine 的存在,实现这种模式是挺简单的。 0.5.2.1. 使用场景 异步 /* * Future */ type Function func(string) (string, error) type Future interface { SuccessCallback() error FailCallback() error Execute(Function) (bool, chan struct{}) } type AccountCache struct { Name string } func (a *AccountCache) SuccessCallback() error { fmt.Println("It's success~") return nil } func (a *AccountCache) FailCallback() error { fmt.Println("It's fail~") return nil } func (a *AccountCache) Execute(f Function) (bool, chan struct{}){ // 空 struct 在 Go 中占的内存是最少的 done := make(chan struct{}) go func(a *AccountCache) { _, err := f(a.Name) if err != nil { _ = a.FailCallback() } else { _ = a.SuccessCallback() } done <- struct{}{} }(a) return true, done } func NewAccountCache(name string) *AccountCache { return &AccountCache{ name, } } func testFuture() { var future Future future = NewAccountCache("Tom") updateFunc := func(name string) (string, error){ fmt.Println("cache update:", name) return name, nil } _, done := future.Execute(updateFunc) defer func() { <-done }() } func main() { var future Future future = NewAccountCache("Tom") updateFunc := func(name string) (string, error){ fmt.Println("cache update:", name) return name, nil } _, done := future.Execute(updateFunc) defer func() { <-done }() // do something } 0.5.3. Pipeline模式 注意和 Barrire 模式不同的是,它是按顺序的,类似于流水线,通过 buffer channel 将多个goroutine串起来,只要前序 goroutine 处理完一部分数据,就往下传递,达到并行的目的。 0.5.3.1. 使用场景 利用多核的优势把一段粗粒度逻辑分解成多个 goroutine 执行 /* * Pipeline 模式 * * 实现一个功能,给定一个切片,然后求它的子项的平方和。 * * 例如,[1, 2, 3] -> 1^2 + 2^2 + 3^2 = 14。 * * 正常的逻辑,遍历切片,然后求平方累加。使用 pipeline 模式,可以把求和和求平方拆分出来并行计算。 */ func generator(max int) <-chan int{ out := make(chan int, 100) go func() { for i := 1; i <= max; i++ { out <- i } close(out) }() return out } func power(in <-chan int) <-chan int{ out := make(chan int, 100) go func() { for v := range in { out <- v * v } close(out) }() return out } func sum(in <-chan int) <-chan int{ out := make(chan int, 100) go func() { var sum int for v := range in { sum += v } out <- sum close(out) }() return out } func main() { // [1, 2, 3] fmt.Println(<-sum(power(generator(3)))) } Worker Pool模式 使用场景 高并发任务 在 Go 中 goroutine 已经足够轻量,甚至 net/http server 的处理方式也是 goroutine-per-connection 的,所以比起其他语言来说可能场景稍微少一些。每个 goroutine 的初始内存消耗在 2~8kb,当我们有大批量任务的时候,需要起很多 goroutine 来处理,这会给系统代理很大的内存开销和 GC 压力,这个时候就可以考虑一下协程池。 /* * Worker pool */ type TaskHandler func(interface{}) type Task struct { Param interface{} Handler TaskHandler } type WorkerPoolImpl interface { AddWorker() // 增加 worker SendTask(Task) // 发送任务 Release() // 释放 } type WorkerPool struct { wg sync.WaitGroup inCh chan Task } func (d *WorkerPool) AddWorker() { d.wg.Add(1) go func(){ for task := range d.inCh { task.Handler(task.Param) } d.wg.Done() }() } func (d *WorkerPool) Release() { close(d.inCh) d.wg.Wait() } func (d *WorkerPool) SendTask(t Task) { d.inCh <- t } func NewWorkerPool(buffer int) WorkerPoolImpl { return &WorkerPool{ inCh: make(chan Task, buffer), } } func main() { bufferSize := 100 var workerPool = NewWorkerPool(bufferSize) workers := 4 for i := 0; i < workers; i++ { workerPool.AddWorker() } var sum int32 testFunc := func (i interface{}) { n := i.(int32) atomic.AddInt32(&sum, n) } var i, n int32 n = 1000 for ; i < n; i++ { task := Task{ i, testFunc, } workerPool.SendTask(task) } workerPool.Release() fmt.Println(sum) } Pub/Sub模式 发布订阅模式是一种消息通知模式,发布者发送消息,订阅者接收消息。 使用场景 消息队列 /* * Pub/Sub */ type Subscriber struct { in chan interface{} id int topic string stop chan struct{} } func (s *Subscriber) Close() { s.stop <- struct{}{} close(s.in) } func (s *Subscriber) Notify(msg interface{}) (err error) { defer func() { if rec := recover(); rec != nil { err = fmt.Errorf("%#v", rec) } }() select { case s.in <-msg: case <-time.After(time.Second): err = fmt.Errorf("Timeout\n") } return } func NewSubscriber(id int) SubscriberImpl { s := &Subscriber{ id: id, in: make(chan interface{}), stop: make(chan struct{}), } go func() { for{ select { case <-s.stop: close(s.stop) return default: for msg := range s.in { fmt.Printf("(W%d): %v\n", s.id, msg) } } }}() return s } // 订阅者需要实现的方法 type SubscriberImpl interface { Notify(interface{}) error Close() } // sub 订阅 pub func Register(sub Subscriber, pub *publisher){ pub.addSubCh <- sub return } // pub 结果定义 type publisher struct { subscribers []SubscriberImpl addSubCh chan SubscriberImpl removeSubCh chan SubscriberImpl in chan interface{} stop chan struct{} } // 实例化 func NewPublisher () *publisher{ return &publisher{ addSubCh: make(chan SubscriberImpl), removeSubCh: make(chan SubscriberImpl), in: make(chan interface{}), stop: make(chan struct{}), } } // 监听 func (p *publisher) start() { for { select { // pub 发送消息 case msg := <-p.in: for _, sub := range p.subscribers{ _ = sub.Notify(msg) } // 移除指定 sub case sub := <-p.removeSubCh: for i, candidate := range p.subscribers { if candidate == sub { p.subscribers = append(p.subscribers[:i], p.subscribers[i+1:]...) candidate.Close() break } } // 增加一个 sub case sub := <-p.addSubCh: p.subscribers = append(p.subscribers, sub) // 关闭 pub case <-p.stop: for _, sub := range p.subscribers { sub.Close() } close(p.addSubCh) close(p.in) close(p.removeSubCh) return } } } func main() { // 测试代码 pub := NewPublisher() go pub.start() sub1 := NewWriterSubscriber(1) Register(sub1, pub) sub2 := NewWriterSubscriber(2) Register(sub2, pub) commands:= []int{1, 2, 3, 4, 5, 6, 7, 8, 9} for _, c := range commands { pub.in <- c } pub.stop <- struct{}{} time.Sleep(time.Second*1) } 注意事项 同步问题,尤其同步原语和 channel 一起用时,容易出现死锁 goroutine 崩溃问题,如果子 goroutine panic 没有 recover 会引起主 goroutine 异常退出 goroutine 泄漏问题,确保 goroutine 能正常关闭

09-函数 阅读更多

使用Go语言进行模块化编程,必须了解的知识主要包括几个重要的数据类型以一些模块化编程的技巧。 在Go语言中函数是一等公民,函数类型也是一等的数据类型。这是函数式编程的重要特征,Go语言在语言层面支持函数式编程。 函数的用途: 封装代码、分割功能、解耦逻辑 作为普通函数值,在其他函数之间传递、赋值变量、做类型判断和转换(就像切片或字典) 函数值可以成为被随意传播的独立逻辑组件(功能模块)。 对于函数类型来说,它是一种对一组输入、输出进行模板化的重要工具,它比接口类型更加轻巧、灵活,它的值也借此变成了可被热替换的逻辑组件。 package main import "fmt" type Printer func(contents string) (n int, err error) // 声明函数类型 func printToStd(contents string) (bytesNum int, err error) { // 实现了函数类型Printer return fmt.Println(contents) } func main() { var p Printer p = printToStd p("something") } 函数的签名包括:函数的参数列表和函数的结果列表,它定义了用来鉴别不同函数的特征,也定义了与函数的交互方式。 函数签名中元素顺序及类型一致,那么就是同一个函数,或是实现了同一个函数类型的函数。 编写高阶函数 高阶函数满足的条件: 接收其他函数作为参数传入 把其他的函数作为结果返回 只要满足其中一个条件就是高阶函数,高阶函数也是函数式编程中重要的概念和特征。 高阶函数可用于实现闭包。 需求:编写calculate函数来实现两个整数间的加减乘除运算,希望两个整数和具体的操作都由该函数的调用方给出。 例子: 接收其他的函数作为参数传入。 // 第一步:声明叫operator的函数类型 type operator func(x,y int) int // 第二步:编写calculate函数 func calculate(x int,y int, op operator)(int, error){ if op == nil { // 卫述语句,判断操作是否合法 return 0 errors.New("invalid operation!") } return op(x,y),nil } // 实现operator,只要函数的签名与operator类型一致就是它的实现 op := func(x, y int) int { return x + y } func main(){ x,y = 12,23 result,err := calculate(x,y,op)) } 卫述语句:被用来检查关键的先决条件的合法性,并在检查未通过的情况下立即终止当前代码块的执行的语句。Go语言中if语句常被作为卫述语句。 例子: 把其他的函数作为结果返回。 // 第一步:声明叫operator和calculateFunc的函数类型 type operator func(x,y int) int type calculateFunc func(x int,y int)(int,err) // 第二步:编写genCalculator函数 func genCalculator(op operate) calculateFunc{ return func(x int,y int)(int, error){ if op == nil{ // 卫述语句 return 0, errors.New("invalid operation!") } return op(x,y),nil } } // 实现operator op := func(x, y int) int { return x + y } func main(){ x,y = 56,78 add := genCalculator(op) result,err = add(x,y) } 闭包 在一个函数中存在对外来标识符(既不代表当前函数的任何参数或结果,也不是函数内部声明的,而是直接从外边拿来的)的引用,也称为自由变量。这里的函数称为闭包函数,就是因为引用了自由变量,而呈现出了一种”不确定“的状态(开放状态)。 闭包体现的是由”不确定“变为”确定“的一个过程。对于Go这样的静态类型语言,在定义的闭包函数的时候,最多只能知道自由变量的类型。 例子: func genCalculator(op operate) calculateFunc { return func(x int, y int) (int, error) { if op == nil { return 0, errors.New("invalid operation") } return op(x, y), nil } } genCalculator只做了一件事,定义一个匿名的calculateFunc类型的函数,并把它作为结果返回。这个匿名函数就是一个闭包函数。它使用的变量op既不代表它的任何参数或结果,也不是它自己声明的,而是定义它的genCalculator函数的参数,所以是一个自由变量(这个自由变量不是在定义闭包函数的时候确定的,而是在genCalculator函数被调用的时候确定的)。 只有给定了该函数的参数op,才知道闭包函数可用于声明运算。 编译到if op == nill语句时,Go编译器上天寻找op所代表的东西,发现op代表的是genCalculator函数的参数,然后把这两者联系起来,此时自由变量op被捕获,这个闭包函数的状态由”不确定“变为”确定“。 闭包的用途:表面上是延迟实现了一部分程序逻辑或功能,实际上是在动态地生成那部分程序逻辑。 传入函数的参数值 package main import "fmt" func main() { array1 := [3]string{"a", "b", "c"} fmt.Printf("The array: %v\n", array1) array2 := modifyArray(array1) fmt.Printf("The modified array: %v\n", array2) // The modified array: [a x c] fmt.Printf("The original array: %v\n", array1) // The original array: [a b c] } func modifyArray(a [3]string) [3]string { a[1] = "x" return a } 原数组没有因为modifyArray()函数而改变,因为所有传给函数的参数值都会被复制,函数在其内部使用的并不是参数值的原值,而是它的副本。 数组是值类型,所以每次复制都会拷贝它,以及它的所有元素值。 对于引用类型(切片、字典、通道),复制值的时候,只会拷贝它本身,并不会拷贝它引用的底层数据。以切片为例,只是拷贝了它指向底层数组中某一个元素的指针,以及它的长度值和容量值,底层数组并不会被拷贝。

10-结构体类型 阅读更多

0.1. 嵌入字段 0.2. 多层嵌入 0.3. 类型组合 0.4. 值方法与指针方法 一个结构体类型包含若干个字段、每个字段都需要有确切的名字和类型。结构体类型也可以不包含任何字段,这并不是没有意义,可以给结构体类型关联上一些方法,把方法看做是函数的特殊版本。 函数是独立的程序实体,可以声明有名字的函数或者匿名函数,还可以把函数当做值传递。可以把具有相同签名的函数抽象成独立的函数类型,作为一组输入、输出的代表。 方法与函数不同,它需要有名字,不能被当做值来看待,最重要的是,它必须隶属于某一个类型。方法所属的类型会通过其声明中的接收者声明体现出来。 接收者声明就是在关键字func和方法名之间的圆括号包裹起来的内容,其中必须包含确切的名称和类型字面量。 例子: // AnimalCategory 代表动物分类学中的基本分类法 type AnimalCategory struct { kingdom string // 界 phylum string // 门 class string // 纲 order string // 目 family string // 科 genus string // 属 species string // 种 } // 隶属于AnimalCategory类型的方法 String(),方法的接收者是ac func (ac AnimalCategory) String() string { return fmt.Sprintf("%s%s%s%s%s%s%s", ac.kingdom, ac.phylum, ac.class, ac.order, ac.family, ac.genus, ac.species) } // 通过方法的接收者ac,可以在其中引用到当前值的任何一个字段, // 或者调用当前值的任何一个方法(包括string方法自己) func main(){ category := AnimalCategory{species: "cat"} fmt.Printf("The animal category: %s\n",category) // 无需显示调用String方法,即可打印出该类型的字符串表示形式 } 在Go语言中,通过为一个类型编写名为String的方法,来自定义该类型的字符串表示形式。这个String方法不需要任何参数声明,但是需要有一个string类型的结果输出。 方法隶属的类型不局限于结构体类型,但必须是某个自定义的数据类型,并且不能是任何接口类型。 一个数据类型关联的所有方法,共同组成了该类型的方法集合 同一个方法集合中的方法不能出现重名,且不能与该类型中任何字段的名称重复 0.1. 嵌入字段 面向对象编程的主要原则:将数据及其操作封装在一起。在Go语言中,把结构体类型中的一个字段看作是一项数据,把隶属它的方法看作是附加在其中数据之上的操作。 例子: type Animal struct { scientificName string // 学名。 AnimalCategory // 动物基本分类。 } // 在结构体类型的某个字段声明中只有一个类型名,则将原类型嵌入到新的类型中 Go语言规范规定,如果一个字段的声明中只有字段的类型名,没有字段的名称,那么就是一个嵌入字段(也称为匿名字段)。通过此类型变量的名称后跟着“.”,在跟着嵌入字段类型的方式引用到该字段,即嵌入字段的类型既是类型也会名称。例如下面的a.AnimalCategory。 func (a Animal) Category() string { return a.AnimalCategory.String() } // Category方法的接收者类型是Animal,接收者名称a, // 通过a.AnimalCategory选择到a的嵌入字段 选择表达式:在某个代表变量的标识符的右边加上“.”,在加上字段名或方法名,表示选择了某个字段或方法 嵌入字段的方法集合会被无条件地合并进被嵌入类型的方法集合中。 animal := Animal{ scientificName: "American Shorthair", AnimalCategory: category, } fmt.Printf("The animal: %s\n", animal) // 此时会直接调用AnimalCategory的String方法 如果Animal也编写自己的String方法,那么嵌入字段的String会被屏蔽。只要名称相同,无论方法的签名是否一致,嵌入字段的方法都会被屏蔽。 因为嵌入字段的字段和方法都可以“嫁接”到被嵌入类型上,所以即使在两个同名的成员一个是字段,另一个是方法的情况下,屏蔽现象依然存在。通过链式选择表达式选择被屏蔽的嵌入字段的字段或方法。 0.2. 多层嵌入 例子: type Cat struct { name string Animal // 嵌入字段本身也是嵌入字段 } func (cat Cat) String() string { return fmt.Sprintf("%s (category: %s, name: %q)", cat.scientificName, cat.Animal.AnimalCategory, cat.name) } 屏蔽现象会以嵌入的层级为依据,嵌入的层级越深的字段或方法越可能被屏蔽。如,调用Cat的String方法: 状态 Cat Animal AnimalCategory 1 存在,调用 屏蔽 屏蔽 2 不存在 调用 屏蔽 3 不存在 不存在 调用 根据是否存在String方法,来判断嵌入字段的String方法是否会被调用会屏蔽。 如果处于同一层级的多个嵌入字段有用同名的字段或方法,那么从被嵌入类型的值那里选择此名称时,会引发编译错,编译器无法确定被选择的成员到底是哪一个。 0.3. 类型组合 Go语言中不存在继承的概念,它通过嵌入字段的方式实现了类型之间的组合。面向对象编程中的继承,通过牺牲一定的代码简洁性来换取可扩展性,这种可扩展性是通过侵入的方式来实现的。 类型之间的组合采用的是非声明的方式,是非侵入式的,它不会破坏类型的封装或加重类型之间的耦合。我们只是把类型当做字段嵌入进来,然后坐享其成地使用嵌入字段所拥有的一切。同时可以通过“包装”或“屏蔽”的方式来调整或优化嵌入字段。 类型组合非常灵活的通过嵌入字段把一个类型的属性和方法“嫁接”给另一个类型,被嵌入类型自然的实现了嵌入字段所实现的接口。组合比继承更加简洁和清晰,不会有多重继承那样复杂的层次结构和可观的管理成本。 接口类型之间也可以组合,以此来扩展接口定义的型号或者标记接口的特征。 0.4. 值方法与指针方法 方法的接收者类型必须是某个自定义的数据类型,不能是接口类型或者接口的指针类型。 值方法:接收者类型是非指针的自定义数据类型的方法 指针方法:接收者类型是指针类型的方法 上面例子中的方法都是值方法。 指针方法,如下例子: func (cat *Cat) SetName(name string) { cat.name = name } // SetName方法的接收者类型是*Cat,所以是一个指针方法 取值表达式:*放在一个指针值的左边,来获取该指针指向的基本类型值 取地址表达式:&放在一个可寻址的基本类型值的左边,来获取该基本类型的指针值 值方法与指针方法的区别: 差别 值方法 指针方法 方法接收者 该方法所属的那个类型值的一个副本,值方法内对该副本的修改不会体现在原值上,除非这个类型本身是某个引用类型的别名类型 该方法所属的那个基本类型值的指针的一个副本,指针方法内对该副本的修改会直接体现在原值上 方法集合 自定义数据类型的方法集合中仅包含所有值方法 该类型的指针类型的方法集合包括所有的值方法和指针方法 方法集合的解释:严格来说,基本类型的值上之只能调用它的值方法,但,Go语言会适时地为我们进行自动地转译,使得我们在这样的值上也能调用它的指针方法。例如,cat.SetName("monster")会自动转译为(&cat).SetName("monster"),即先取cat的指针值,然后在指针值上调用指针方法SetName。 一个类型的方法集合中有哪些方法与它能实现哪些接口类型是息息相关的,如果一个基本类型和它的指针类型的方法集合是不同的,那么它们具体实现的接口类型的数量也会有差异,除非两个数量都是零。

11-接口类型 阅读更多

0.1. 专有名词 0.2. 给接口变量赋值 0.2.1. 接口类型值的存储方式和结构 0.3. 接口变量的值在什么情况下才真正为nil 0.4. 接口之间的组合 接口类型与其他数据类型不同,它没法被实例化。既不能通过调用new()或make()函数创建出一个接口类型的值,也无法用字面量来表示一个接口类型的值。 对于某一个接口类型,如果没有任何数据类型可以作为它的实现,那么该接口的值就不可能存在。 接口类型的类型字面量与结构体类型看起来相似,用花括号包裹一些核心信息: 结构体类型:包裹字段声明 接口类型:包裹方法定义 接口类型声明中的方法代表的就是该接口的方法集合,一个接口的方法集合就是它的全部特征。 对于任何数据类型,只要它的方法集合中完全包含了一个接口的全部特征,那么它就一定是这个接口的实现类型,如下所示: type Pet interface { SetName(name string) Name() string Category() string } // 只要一个数据类型的方法集合中有上述3个方法,那它就是Pet接口的实现类型 // 这是一种无侵入式的接口实现方式 判断一个数据类型的某个方法实现的是某个接口类型中的方法: 两个方法的签名要完全一致 两个方法的名称要完全一致 0.1. 专有名词 动态值:赋给接口类型的变量的值叫做实际值(动态值) 动态类型:被赋予给接口类型的变量的值的类型称为接口类型变量的实际类型(动态类型) dog := Dog{"little pig"} var pet Pet = &dog // &dog是动态值,*dog是动态类型 // pet的类型是静态类型 pet的静态类型永远不变,而动态类型会随着赋值的变化而变化。在给一个接口类型的变量赋予实际值之前,它的动态类型是不存在的。 0.2. 给接口变量赋值 如果使用一个变量给另外一个变量赋值,那么真正赋值给后者的,并不是前者持有的那个值,而是该值的一个副本。 0.2.1. 接口类型值的存储方式和结构 接口类型本身是无法被值化的,在赋予接口变量动态值之前,它的值一定是nil(它的零值)。当给接口变量赋值时,该变量的动态类型和动态值一起被存储在一个专用的数据结构(iface)中。这个接口变量的值其实是这个专用数据结构的实例,而不是赋予给接口变量的那个动态值。 所以接口变量与动态值肯定是不同的,无论是存储的内容还是存储的结构都不同。 专用数据结构(iface)实例会包含两个指针,一个是指向类型信息(这里的类型信息有另一个专用数据结构的实例承载,包含动态类型以及使他实现了接口的方法和调用它们的途径)的指针,另一个是指向动态值的指针。 0.3. 接口变量的值在什么情况下才真正为nil var dog1 *Dog // 声明*Dog类型的变dog1,没有初始化,dog1的值为nil fmt.Println("The first dog is nil. ") dog2 := dog1 // dog2的值也为nil fmt.Println("The second dog is nil. ") var pet Pet = dog2 // pet的动态值不为nil,pet的动态类型为*Dog if pet == nil { fmt.Println("The pet is nil. ") } else { fmt.Println("The pet is not nil. ") } // dog2的值是真正的nil,把dog2赋值给pet时,Go语言把值和类型放在一起考虑 // 因此pet的动态值不是nil // Go语言识别出赋予pet的值是一个*Dog类型的nil值, // Go语言用iface的实例包装它,包装后pet的值肯定不是nil 在Go语言中,把字面量为nil表示的值叫做无类型的nil,这是真正的nil,它的类型也是nil。 把一个有类型的nil值赋给接口变量,那么这个变量的值一定不会是那个真正的nil。 让接口变量的值为真正的nil的方法: 只声明接口变量但不初始化 直接把字面量nil赋予给接口变量 0.4. 接口之间的组合 接口类型之间的嵌入称为接口的组合,与结构体类型的组合相比,接口类型组合不会出现方法之间的屏蔽。只要组合的接口之间有同名的方法就会产生冲突,从而无法通过编译,即使同名方法的签名不同也不行。 与结构体组合相似,把接口类型的名称直接写到另一个接口类型的成员类别中,如下所示: type Animal interface { ScientificName() string Category() string } type Pet interface { Animal // 嵌入 Name() string } // Animal接口包含的所以方法成为了Pet接口的方法 Go语言团队鼓励声明体量较小的接口,并建议通过接口组合来扩展程序、增加程序的灵活性。相比于包含很多方法的大接口,小接口可以专用地表达某一种能力或某一类特征,也更容易被组合在一起。

12-指针 阅读更多

type Dog struct { // 结构体类型,基本类型 name string } func (dog *Dog) SetName(name string) { // *Dog 指针类型 dog.name = name } SetName方法的接收值就是Dog类型的指针值,通过指针值无缝访问基本值包含的任何字段、以及调用与之关联的任何方法。 指针是一个指向某个确切的内存地址的值,这个内存地址可以是任何数据或代码的起始地址,如,某个变量、字段、函数。 Go语言中的指针 Go语言内建的数据类型:uinptr类型是一个数值类型,最贴近传统意义上的指针。根据计算机架构的不同,它可以存储32或64为的无符号整数,可以代表任何指针的位(bit)模式,也就是原始的内存地址。 标准库unsafe包中的Pointer类型,代表了指针。unsafe.Pointer可以表示任何指向可寻址的值的指针,它也是指针值和uintptr值之间的桥梁。通过它可以在这两者之间转换。 可寻址的 不可寻址的值: package main type Named interface { // Name 用于获取名字。 Name() string } type Dog struct { name string } func (dog *Dog) SetName(name string) { dog.name = name } func (dog Dog) Name() string { return dog.name } func main() { // 示例1。 const num = 123 //_ = &num // 常量不可寻址。 //_ = &(123) // 基本类型值的字面量不可寻址。 var str = "abc" _ = str //_ = &(str[0]) // 对字符串变量的索引结果值不可寻址。 //_ = &(str[0:2]) // 对字符串变量的切片结果值不可寻址。 str2 := str[0] _ = &str2 // 但这样的寻址就是合法的。( //_ = &(123 + 456) // 算术操作的结果值不可寻址。 num2 := 456 _ = num2 //_ = &(num + num2) // 算术操作的结果值不可寻址。 //_ = &([3]int{1, 2, 3}[0]) // 对数组字面量的索引结果值不可寻址。 //_ = &([3]int{1, 2, 3}[0:2]) // 对数组字面量的切片结果值不可寻址。 _ = &([]int{1, 2, 3}[0]) // 对切片字面量的索引结果值却是可寻址的。 //_ = &([]int{1, 2, 3}[0:2]) // 对切片字面量的切片结果值不可寻址。 //_ = &(map[int]string{1: "a"}[0]) // 对字典字面量的索引结果值不可寻址。 var map1 = map[int]string{1: "a", 2: "b", 3: "c"} _ = map1 //_ = &(map1[2]) // 对字典变量的索引结果值不可寻址。 //_ = &(func(x, y int) int { // return x + y //}) // 字面量代表的函数不可寻址。 //_ = &(fmt.Sprintf) // 标识符代表的函数不可寻址。 //_ = &(fmt.Sprintln("abc")) // 对函数的调用结果值不可寻址。 dog := Dog{"little pig"} _ = dog //_ = &(dog.Name) // 标识符代表的函数不可寻址。 //_ = &(dog.Name()) // 对方法的调用结果值不可寻址。 //_ = &(Dog{"little pig"}.name) // 结构体字面量的字段不可寻址。 //_ = &(interface{}(dog)) // 类型转换表达式的结果值不可寻址。 dogI := interface{}(dog) _ = dogI //_ = &(dogI.(Named)) // 类型断言表达式的结果值不可寻址。 named := dogI.(Named) _ = named //_ = &(named.(Dog)) // 类型断言表达式的结果值不可寻址。 var chan1 = make(chan int, 1) chan1 <- 1 //_ = &(<-chan1) // 接收表达式的结果值不可寻址。 } 不可寻址的总结: 不可变的:常量、基本类型字面量、字符串、函数以及方法的字面量 临时结果:算术操作的结果,对数组值、切片值、字典值字面量施加的表达式的求值结果;如果把一个临时结果赋给一个变量,那它就是可寻址的 不安全: 字典类型的变量施加索引值得到的结果不属于临时结果,但是字典中键值对的地址是会变化的,获取指针不安全 对函数或方法的调用属于临时结果、拿到一段代码的指针是不安全的 表达式整理: 索引表达式 切片表示式 选择表达式 调用表达式 类型转换表达式 类型断言表达式 向通道发送或者从通道接收的接收表达式 对数组或切片类型的变量的索引或切片的结果值不是临时结果。 不可寻址的值的使用限制 无法使用取地址操作符(&)获取指针;对不可寻址值取地址,编译器会报错 在上述例子的基础上编写新函数: func New(name string) Dog { return Dog{name} } func main() { New("little pig").SetName("monster") // 编译报错,不能取得New("little pig")的地址 } 例外情况: ++或--的左边是一个表达式(表达式的结果值必须是可寻址的),就可以组成一个自增或自减语句,所以针对值字面量的表达式几乎都无法在这里使用。字典字面量和索引表达式可以用。 赋值语句中,赋值操作左边的表达式的结果必须是可寻址的,字典的索引结果可用 带range子句的for循环中,range关键字左边的表达式的结果值必须是可寻址的,字典的索引结果可用

13-语句执行规则 阅读更多

0.1. 进程与线程 0.1.1. 进程 0.1.1.1. 进程的状态 0.1.1.2. 进程的控制结构 0.1.1.3. 进程的上下文切换 0.1.1.4. 发生进程上下文切换的场景 进程间通信 0.1.2. 线程 0.1.2.1. 线程分类 0.1.2.1.1. 用户线程 0.1.2.1.2. 内核线程 0.1.2.1.3. 轻量级进程 0.1.2.2. 线程与进程的比较 0.1.3. 硬件设备访问速度的差异 0.1.4. 并行与并发 0.1.5. 多线程安全问题 0.1.5.1. 可见性问题 0.1.5.2. 原子性问题 0.1.5.3. 有序性问题 0.1.5.4. 活跃性问题 0.1.6. 多线程安全问题解决方案 0.1.6.1. 同步机制 0.1.7. 多线程性能问题 0.2. 调度 0.2.1. 调度时机 0.2.2. 调度原则 0.2.3. 调度算法 0.2.3.1. 单核系统 0.3. 并发模型 0.3.1. 并行worker 0.3.2. 流水线(事件驱动系统) 0.3.2.1. Actor模型 0.3.2.2. Channels模型 0.4. Golang Runtime 调度器 P和M的数量 并发模型 并发控制 0.5. goroutine最佳实践 0.5.1. 已经存在的用户级线程会被优先复用 0.5.2. 用户级线程的创建成本很低 0.6. 主用户级线程等待其他用户级线程 0.7. 用户级线程按顺序执行 0.8. 系统调用 0.9. 系统线程方面的限制 Don't communicate by sharing memory; share memory by communicating. 不要通过共享数据来通讯,以通讯的方式来共享数据。 channel类型的值,被用来以通讯的方式共享数据。一般被用来在不同的goroutine(代表并发编程模型中的用户级线程)之间传递数据。 0.1. 进程与线程 进程:【资源分配单位】描述的就是程序的执行过程,是运行着的程序的代表,一个进程其实就是某个程序运行的一个产物。(静静躺在那里的代码是程序、奔跑着、正在发挥功能的代码就是进程)。 线程:【CPU调度单位】总是在进程之内,被视为进程中运行着的控制流(代码执行流程)。 每个进程中的内容 每个线程中的内容 地址空间 程序计数器 全局变量 寄存器 文件句柄 栈 子进程 状态 即将发送的定时器 信号与信号处理程序 账户信息 一个进程至少包含一个线程,如果只包含一个线程,那么所有代码会被串行执行。每个进程的第一个线程都会随着该进程的启动而被创建,称它为所属进程的主线程。 如果一个进程包含多个线程,那么代码可以被并发(单个CPU)地执行,除了主线程,其他线程都是进程中已存在的线程创建出来的。主线程之外的其他线程,只能由代码显式地创建和销毁,各个线程之间可以共享地址空间和文件等资源。但是,当进程中的一个线程奔溃时,会导致其所属进程的所有线程奔溃。 0.1.1. 进程 0.1.1.1. 进程的状态 运行状态(Runing):该时刻进程占用 CPU; 就绪状态(Ready):可运行,但因为其他进程正在运行而暂停停止; 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行; 创建状态(new):进程正在被创建时的状态; 结束状态(Exit):进程正在从系统中消失时的状态; NULL -> 创建状态:一个新进程被创建时的第一个状态; 创建状态 -> 就绪状态:当进程被创建完成并初始化后,一切就绪准备运行时,变为就绪状态,这个过程是很快的; 就绪态 -> 运行状态:处于就绪状态的进程被操作系统的进程调度器选中后,就分配给 CPU 正式运行该进程; 运行状态 -> 结束状态:当进程已经运行完成或出错时,会被操作系统作结束状态处理; 运行状态 -> 就绪状态:处于运行状态的进程在运行过程中,由于分配给它的运行时间片用完,操作系统会把该进程变为就绪态,接着从就绪态选中另外一个进程运行; 运行状态 -> 阻塞状态:当进程请求某个事件且必须等待时,例如请求 I/O 事件; 阻塞状态 -> 就绪状态:当进程要等待的事件完成时,它从阻塞状态变到就绪状态; 特殊状态:挂起,表示进程没有占有物理内存空间。 由于虚拟内存管理原因,进程的所使用的空间可能并没有映射到物理内存,而是在硬盘上,这时进程就会出现挂起状态,另外调用 sleep 也会被挂起。 挂起状态可以分为两种: 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现; 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行; 0.1.1.2. 进程的控制结构 在操作系统中,是用进程控制块(process control block,PCB)数据结构来描述进程的。 PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。 PCB包含的内容: 进程描述信息: 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务 进程控制和管理信息: 进程当前状态,如 new、ready、running、waiting 或 blocked 等 进程优先级:进程抢占 CPU 时的优先级 资源分配清单:有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息 CPU 相关信息:CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行 PCB通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如: 将所有处于就绪状态的进程链在一起,称为就绪队列 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列 对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序 0.1.1.3. 进程的上下文切换 各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换。 CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文 CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。 系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。 上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。 进程是由内核管理和调度的,所以进程的切换只能发生在内核态。 所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。 0.1.1.4. 发生进程上下文切换的场景 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待 CPU 的进程运行; 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行; 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度; 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行; 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序; 进程间通信 由于多进程地址空间不同,数据不能共享,一个进程内创建的变量在另一个进程是无法访问。于是操作系统提供了各种系统调用,搭建起各个进程间通信的桥梁,这些方法统称为进程间通信 IPC (IPC InterProcess Communication) 常见进程间通信方式: 匿名管道 Pipe:实质是一个内核缓冲区,进程以先进先出 FIFO 的方式从缓冲区存取数据。是一种半双工的通信方式,数据只能单向流动,而且只能在具有亲缘关系(父子进程间)的进程间通信。 命名管道 FIFO:提供了一个路径名与之关联,以文件形式存在于文件系统中,这样即使不存在亲缘关系的进程,只要可以访问该路径也能相互通信,支持同一台计算机的不同进程之间,可靠的、单向或双向的数据通信。 信号Signal:用于进程间互相通信或者操作的一种机制,可以在任何时候发给某一进程,无需知道该进程的状态。如果该进程当前不是执行态,内核会暂时保存信号,当进程恢复执行后传递给它。如果一个信号被进程设置为阻塞,则该信号的传递被延迟,直到其阻塞被取消时才被传递给进程。信号在用户空间进程和内核之间直接交互,内核可以利用信号来通知用户空间的进程发生了哪些系统事件,信号事件主要有两个来源: 硬件来源:用户按键输入Ctrl+C退出、硬件异常如无效的存储访问等。 软件终止:终止进程信号、其他进程调用 kill 函数、软件异常产生信号。 消息队列 Message Queue:消息队列是存放在内核中的消息链表,每个消息队列由消息队列标识符表示,只有在内核重启或主动删除时,该消息队列才会被删除。 共享内存 Shared memory:一个进程把地址空间的一段,映射到能被其他进程所访问的内存,一个进程创建、多个进程可访问,进程就可以直接读写这一块内存而不需要进行数据的拷贝,从而大大提高效率。(是最快的可用 IPC 形式,是针对其他通信机制运行效率较低而设计的)。 套接字 Socket:( TCP/IP 协议栈,也是建立在 socket 通信之上),它是一种通信机制,凭借这种机制,既可以在本机进程间通信,也可以跨网络通过,因为,套接字通过网络接口将数据发送到本机的不同进程或远程计算机的进程。 0.1.2. 线程 0.1.2.1. 线程分类 系统级线程(Kernal Thread):在Go语言的运行时中,系统会帮助我们自动地创建和销毁系统级的线程(操作系统提供的线程)。 用户级线程(User Thread):架设在系统级线程之上,由用户(我们编写的程序)完全控制的代码执行流程,用户级线程的创建、销毁、调度、状态变更以及其中的代码和数据都完全需要程序自己去实现和处理。 优势:用户级线程的创建和销毁不通过操作系统,速度快,不用等待操作系统去调度它们运行,所以容易控制且灵活。 劣势:复杂,如果只是用系统级线程,那么只要指明需要新线程执行的代码片段,并且下达创建或销毁线程的指令就好,其他的具体实现由操作系统代劳。我们必须全权负责与用户级线程相关的所有具体的实现,并且需要和操作系统争取对接,否则可能无法正确运行。 轻量级进程(Lightweight Process):在内核中来支持用户线程 用户线程和内核线程的对应关系: 多个用户线程对应同一个内核线程 一个用户线程对应一个内核线程 多个用户线程对应到多个内核线程 0.1.2.1.1. 用户线程 用户线程是基于用户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。 所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。 用户线程的优点: 每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统; 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快; 用户线程的缺点: 由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了。 当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢; 0.1.2.1.2. 内核线程 内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。 内核线程的优点: 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行; 分配给线程,多线程的进程获得更多的 CPU 运行时间; 内核线程的缺点: 在支持内核线程的操作系统中,由内核来维护进程和线程的上下问信息,如 PCB 和 TCB; 线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大; 0.1.2.1.3. 轻量级进程 轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持。 1 : 1 模式: 一个线程对应到一个 LWP 再对应到一个内核线程,如上图的进程 4,属于此模型。 优点:实现并行,当一个 LWP 阻塞,不会影响其他 LWP; 缺点:每一个用户线程,就产生一个内核线程,创建线程的开销较大。 N : 1 模式:多个用户线程对应一个 LWP 再对应一个内核线程,如上图的进程 2,线程管理是在用户空间完成的,此模式中用户的线程对操作系统不可见。 优点:用户线程要开几个都没问题,且上下文切换发生用户空间,切换的效率较高; 缺点:一个用户线程如果阻塞了,则整个进程都将会阻塞,另外在多核 CPU 中,是没办法充分利用 CPU 的。 M : N 模式:根据前面的两个模型混搭一起,就形成 M:N 模型,该模型提供了两级控制,首先多个用户线程对应到多个 LWP,LWP 再一一对应到内核线程,如上图的进程 3。 优点:综合了前两种优点,大部分的线程上下文发生在用户空间,且多个线程又可以充分利用多核 CPU 的资源。 组合模式:如上图的进程 5,此进程结合 1:1 模型和 M:N 模型。可以针对不同的应用特点调节内核线程的数目来达到物理并行性和逻辑并行性的最佳方案。 0.1.2.2. 线程与进程的比较 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位; 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈; 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系; 线程能减少并发执行的时间和空间开销; 对于,线程相比进程能减少开销,体现在: 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们; 线程的终止时间比进程快,因为线程释放的资源相比进程少很多; 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的; 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了; 所以,线程比进程不管是时间效率,还是空间效率都要高。 所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。 对于线程和进程,我们可以这么理解: 当进程只有一个线程时,可以认为进程就等于线程; 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源,这些资源在上下文切换时是不需要修改的; 另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。 0.1.3. 硬件设备访问速度的差异 CPU、内存和I/O设备之间速度差异: 访问时间 设备类型 容量 1ns 寄存器 1KB 2ns 高速缓存 4MB 10ns 主存 16GB 10ms 磁盘 4TB 解决方式: CPU 使用缓存来中和和内存的访问速度差异 操作系统提供进程和线程调度,让 CPU 在执行指令的同时分时复用线程,让内存和磁盘不断交互,不同的 CPU 时间片能够执行不同的任务,从而均衡这三者的差异 编译程序提供优化指令的执行顺序,让缓存能够合理的使用 0.1.4. 并行与并发 并发:CPU分时执行多个任务(指令),切换任务前把没完成的当前任务的状态暂存起来 并行:多个CPU同时处理多个任务(指令) 合理使用线程是一门艺术,合理编写一道准确无误的多线程程序更是一门艺术。 0.1.5. 多线程安全问题 在没有采用同步机制的情况下,多个线程中的执行操作往往是不可预测的。 0.1.5.1. 可见性问题 单核时代,所有的线程共用一个 CPU,CPU 缓存和内存的一致性问题容易解决。 多核时代,每个核都独立的运行一个线程,每颗 CPU 都有自己的缓存,这时 CPU 缓存与内存的数据一致性就没那么容易解决了,当多个线程在不同的 CPU 上执行时,这些线程操作的是不同的 CPU 缓存。 0.1.5.2. 原子性问题 如果对一个变量的操作分为多个步骤,多线程执行的过程中,无论是并发还是并行,由于可见性问题,线程单独操作变量,并将结果写回内存,将会导致变量结果与预期不一致。 因此在多线程中,需要保证对变量的操作是原子性的,即这个操作是一个原子操作(要么全部执行,要么全部不执行)。 原子性操作是完全独立于任何其他进程运行的操作,原子操作多用于现代操作系统和并行处理系统中。原子操作通常在内核中使用,因为内核是操作系统的主要组件。但是,大多数计算机硬件,编译器和库也提供原子性操作。 0.1.5.3. 有序性问题 编译器有时候确实是 「好心办坏事」,它为了优化系统性能,往往更换指令的执行顺序。 0.1.5.4. 活跃性问题 死锁:每个线程都在等待其他线程释放资源,而其他资源也在等待每个线程释放资源,这样没有线程抢先释放自己的资源,产生死锁无线等待下去。 四个必要条件,破坏一个就不会死锁: 互斥:资源排他性使用 请求和保持:已持有资源的线程请求新资源(新资源处于占用状态),请求阻塞 不剥夺:已占用的资源只能由占用者释放 循环等待 活锁:两个并行线程尝试获取另一个锁失败后,释放自己持有的锁,过程一直重复,虽然没有线程阻塞,但任务没有向下执行。 0.1.6. 多线程安全问题解决方案 当多个线程共享资源,即同时对一共享数据进行修改,从而影响程序运行的正确性时,就称为竞态条件。 线程安全的核心是对状态访问操作进行管理,只要共享变量或变量状态可变就会出现问题。 采用同步机制 不在多线程中共享变量且将变量设置为不可变 0.1.6.1. 同步机制 解决原子性:互斥锁、读写锁、自旋锁、条件变量、信号量 解决可见性:volatile: 0.1.7. 多线程性能问题 在并发场景下,线程切换(上下文切换)这个操作开销很大,把大量的时间消耗在线程切换上而不是线程运行上。 线程上下文切换时,要看它们是不是属于同一个进程: 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样; 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据; context:上下文切换的资源,寄存器的状态,程序计数器,栈等。 切换过程包括: 暂停当前线程 保存当前状态 选择合适线程 加载新的状态 执行线程代码 引起切换的原因: 当前正在执行的任务完成,系统的 CPU 正常调度下一个需要运行的线程 当前正在执行的任务遇到 I/O 等阻塞操作,线程调度器挂起此任务,继续调度下一个任务 多个任务并发抢占锁资源,当前任务没有获得锁资源,被线程调度器挂起,继续调度下一个任务 用户的代码挂起当前任务,比如线程执行 sleep() 方法,让出CPU 使用硬件中断的方式引起上下文切换 0.2. 调度 0.2.1. 调度时机 就绪态 -> 运行态:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行; 运行态 -> 阻塞态:当进程发生 I/O 事件而阻塞时,操作系统必须另外一个进程运行; 运行态 -> 结束态:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行; 0.2.2. 调度原则 CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率; 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量; 周转时间:周转时间是进程运行和阻塞时间总和,一个进程的周转时间越小越好; 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意; 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。 0.2.3. 调度算法 抢占式 非抢占式 0.2.3.1. 单核系统 先来先服务调度算法 最短作业优先调度算法 高响应比优先调度算法 时间片轮转调度算法 最高优先级调度算法 多级反馈队列调度算法 0.3. 并发模型 并发模型其实和分布式系统模型非常相似,在并发模型中是线程彼此进行通信,而在分布式系统模型中是 进程 彼此进行通信。然而本质上,进程和线程也非常相似。 分布式系统通常要比并发系统面临更多的挑战和问题比如进程通信、网络可能出现异常,或者远程机器挂掉等 并发模型同样面临着比如 CPU 故障、网卡出现问题、硬盘出现问题等 0.3.1. 并行worker 这些共享状态可能会使用一些工作队列来保存业务数据、数据缓存、数据库的连接池等。 在线程通信中,线程需要确保共享状态是否能够让其他线程共享,而不是仅仅停留在 CPU 缓存中让自己可用,当然这些都是程序员在设计时就需要考虑的问题。 多线程在访问共享数据时,会丢失并发性,因为操作系统要保证只有一个线程能够访问数据,这会导致共享数据的争用和抢占。未抢占到资源的线程会阻塞。 0.3.2. 流水线(事件驱动系统) 0.3.2.1. Actor模型 在 Actor 模型中,每一个 Actor 其实就是一个 Worker, 每一个 Actor 都能够处理任务。 Actor 模型是一个并发模型,它定义了一系列系统组件应该如何动作和交互的通用规则。一个参与者Actor对接收到的消息做出响应,然后可以创建出更多的 Actor 或发送更多的消息,同时准备接收下一条消息。 Actor 模型重在参与交流的实体(即进程),而 CSP 重在交流的通道,如 Go 中的 channel。 0.3.2.2. Channels模型 也叫CSP(Communicating sequential processes)。 在 Channel 模型中,worker 通常不会直接通信,与此相对的,他们通常将事件发送到不同的 通道(Channel)上,然后其他 worker 可以在这些通道上获取消息。 有的时候 worker 不需要明确知道接下来的 worker 是谁,他们只需要将作者写入通道中,监听 Channel 的 worker 可以订阅或者取消订阅,这种方式降低了 worker 和 worker 之间的耦合性。 与 Actor 相比,CSP 最大的优点是灵活性。Actor 模型,负责通信的媒介和执行单元是耦合的。而 CSP 中,channel 是第一类对象,可以被独立创造、写入、读出数据,也可以在不同执行单元中传递。 CSP 模型也易受死锁影响,且没有提供直接的并行支持。并行需要建立在并发基础上,引入了不确定性。 CSP 模型不关注发送消息的进程,而是关注发送消息时使用的 channel,而 channel 不像 Actor 模型那样进程与队列紧耦合。而是可以单独创建和读写,并在进程 (goroutine) 之间传递。 0.4. Golang Runtime 调度器 Go语言拥有: 独特的并发编程模型 用户级线程goroutine 强大的用于调度goroutine、对接操作系统的调度器 这个调度器是Go语言运行时系统的重要组成部分,主要负责统筹调配Go并发编程模型中的三个主要元素: G(goroutine):用户级线程 P(processor):可以承载若干个goroutine,且能够使这些goroutine适时地与系统级线程对接,并得到真正运行的中介 M(machine):系统级线程 宏观上讲,由于P的存在,用户级线程和系统级线程可以呈现多对多的关系。 例如: 当一个正在与某个系统级线程对接并运行的用户级线程,需要因某个事件(等待IO或锁的解除)而暂停运行的时候,调度器总会及时发现,并把这个用户级线程和系统级线程分离,以释放计算资源供其他等待运行的用户级线程使用。 当一个用户级线程需要恢复运行的时候,调度器又会尽快为它寻找空闲的计算资源(包括系统级线程)并安排运行。 当系统级线程不够用的时候,调度器会帮我们向操作系统申请新的系统级线程。 当某一个系统级线程已经无用时,调度器会负责把它及时销毁掉。 因为调度器帮我们做了很多事,所以Go程序才能高效地利用操作系统和计算机资源。程序中所有的用户级线程都会被充分地调度,其中的代码也都会并发地运行,即使用户级线程有数十万计。 全局队列(Global Queue):存放等待运行的G。 P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G'时,G'优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。 P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS(可配置)个。 M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列拿一批G放到P的本地队列,或从其他P的本地队列偷一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。 P和M的数量 P的数量:由启动时环境变量$GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定。这意味着在程序执行的任意时刻都只有$GOMAXPROCS个goroutine在同时运行。 M的数量: (1) go语言本身的限制:go程序启动时,会设置M的最大数量,默认10000.但是内核很难支持这么多的线程数,所以这个限制可以忽略 (2)runtime/debug中的SetMaxThreads函数,设置M的最大数量 (3)一个M阻塞了,会创建新的M 并发模型 Go 是采用 CSP 的思想的,channel 是 go 在并发编程通信的推荐手段,Go 语言推荐使用通信来进行进程间同步消息。这样做有三点好处: 首先,使用发送消息来同步信息相比于直接使用共享内存和互斥锁是一种更高级的抽象,使用更高级的抽象能够为我们在程序设计上提供更好的封装,让程序的逻辑更加清晰; 其次,消息发送在解耦方面与共享内存相比也有一定优势,我们可以将线程的职责分成生产者和消费者,并通过消息传递的方式将它们解耦,不需要再依赖共享内存; 最后,Go 语言选择消息发送的方式,通过保证同一时间只有一个活跃的线程能够访问数据,能够从设计上天然地避免线程竞争和数据冲突的问题; 并发控制 sync.WaitGroup:某任务需要多 goroutine 协同工作,每个 goroutine 只能做该任务的一部分,只有全部的 goroutine 都完成,任务才算是完成 channel+select:比较优雅的通知一个 goroutine 结束;多groutine中数据传递 context:多层级groutine之间的信号传播(包括元数据传播,取消信号传播、超时控制等),优雅的解决了 goroutine 启动后不可控的问题 0.5. goroutine最佳实践 package main import "fmt" func main() { for i := 0; i < 10; i++ { go func() { fmt.Println(i) }() } } 代码执行后,不会有任何内容输出。 与一个进程总会有一个主线程类似,每一个独立的Go程序运行起来总会有一个主用户线程(goroutine)。这个主goroutine会在Go程序的运行准备工作完成后被自动地启用,并不需要任何手动操作。 每条go语句一般都会携带一个函数调用,这个被调用的函数被称为go函数,主用户线程(goroutine)的go函数,就是那个程序入口的main函数。 0.5.1. 已经存在的用户级线程会被优先复用 go函数被真正执行的时间,总会与其所属的go语句被执行的时间不同。当程序执行到一条go语句,Go语言运行时系统,会先试图从某个存放空闲的用户级线程的队列中获取某个用户级线程,它只有找不到空闲的用户级线程的情况们才会去创建一个新的用户级线程。 0.5.2. 用户级线程的创建成本很低 创建一个新的用户级线程并不会像创建一个进程或者一个系统级线程那样,必须通过操作系统的系统调用来完成,在Go语言的运行时系统内部就可以完成了,一个用户级线程就相当于需要并发执行代码片段的上下文环境。 在拿到空闲的用户级线程之后,Go语言运行时系统会用这个用户级线程去包装那个go函数(函数中的代码),然后再把这个用户级线程追加到某个可运行的用户级线程队列(先进先出)中。虽然在队列中被安排运行的时间很快,上述的准备工作也不可避免,因此存在一定时间消耗。所以go函数的执行时间,总是会明显滞后(相对于CPU和Go程序)于go语句的执行时间。 只要go语句本身执行完毕,Go程序完全不用等待go函数的执行,它会立刻去执行后面的语句,这就是异步并发执行。 注意:一旦主用户级线程(main函数中的那些代码)执行完毕,当前的Go程序就会结束运行。如果在Go程序结束的那一刻,还有用户级线程没有运行,那就没有机会运行了。 严格的说,Go语言并不会保证用户级线程会以怎样的顺序运行,因为主用户级线程会与手动启动的其他用户级线程一起接受调度,又因为调度器很可能会在用户级线程中的代码只执行了一部分的时候暂停,以期所有的用户级线程有更公平的运行机会。所以哪个用户级线程先执行完,是不可预知的,除非使用了某种Go语言提供的方式进行人为干预。 0.6. 主用户级线程等待其他用户级线程 让主用户级线程Sleep()一会:但是时间难以把握 其他用户级线程运行完毕之后发出通知:创建一个通道,长度与手动启动的用户级线程一致,每个用户级线程运行完毕的时候向通道中发送一个值(在go函数的最后发送),在main函数的最后接收通道中的值,接收次数与手动启动的用户级线程数量一直 sync包中的sync.WaitGroup类型 sign := make(chan struct{}, num) // 结构体类型的通道 sign <- struct{}{} <- sign struct{}类似于空接口interface{},代表既不包含任何字段也不拥有任何方法的空结构体类型。 struct{}类型的值的表示方法只有一个:struct{}{},它占用的内存空间是0字节。这个值在整个Go程序中永远都只会存在一份。无数次的试用这个值的字面量,但是用到的却是同一个值。 0.7. 用户级线程按顺序执行 package main import ( "fmt" "sync/atomic" "time" ) func main() { // 用户级线程随机执行 for i := 0; i < 10; i++ { go func() { fmt.Println(i) }() } // 用户级线程按顺序执行 for i := 0; i < 10; i++ { go func(i int) { // 让go函数接收一个int型参数,在调用它的时候,把变量传进去 fmt.Println(i) // 这样Go语言包装每个用户级线程都可以拿到一个唯一的整数 }(i) } // 在go语句被执行时,传给go函数的参数`i`会被先求值,如此就得到了当次迭代的序号, // 之后,无论go函数会在什么时候执行,这个参数值都不会变, // 也就是go函数中调用`fmt.Prinrln`函数打印的一定是那个当次迭代的序号。 var count uint32 for i := uint32(0); i < 10; i++ { go func(i uint32) { fn := func() { fmt.Println(i) } trigger(i, fn) }(i) } trigger := func(i uint32, fn func()) { for { if n := atomic.LoadUint32(&count); n == i { // 原子操作 fn() atomic.AddUint32(&count, 1) // 原子操作 break } time.Sleep(time.Nanosecond) } } trigger(10, func(){}) } 0.8. 系统调用 Go 会优化系统调用(无论阻塞与否),通过运行时封装它们。封装的那一层会把 P 和线程 M 分离,并且可以让另一个用户线程在它上面运行。下面以文件读取举例: func main() { buf := make([]byte, 0, 2) fd, _ := os.Open("number.txt") fd.Read(buf) fd.Close() println(string(buf)) // 42 } 文件读取的流程如下: P0 现在在空闲 list 中,有可能被唤醒。当系统调用 exit 时,Go 会遵守下面的规则,直到有一个命中了。 尝试去捕获相同的 P,在我们的例子中就是 P0,然后 resume 执行过程 尝试从空闲 list 中捕获一个 P,然后 resume 执行过程 把 G 放到全局队列里,把与之相关联的 M 放回空闲 list 去 然而,在像 http 请求等 non-blocking I/O 情形下,Go 在资源没有准备好时也会处理请求。在这种情形下,第一个系统调用 — 遵循上述流程图 — 由于资源还没有准备好所以不会成功,(这样就)迫使 Go 使用 network poller 并使协程停驻,如下示例。 func main() { http.Get(`https://httpstat.us/200`) } 当第一个系统调用完成且显式地声明了资源还没有准备好,G 会在 network poller 通知它资源准备就绪之前一直处于停驻状态。在这种情形下,线程 M 不会阻塞: 在 Go 调度器在等待信息时 G 会再次运行。调度器在获取到等待的信息后会询问 network poller 是否有 G 在等待被运行。 如果多个协程都准备好了,只有一个会被运行,其他的会被加到全局的可运行队列中,以备后续的调度。 0.9. 系统线程方面的限制 在系统调用中,Go 不会限制可阻塞的 OS 线程数。 GOMAXPROCS 变量表示可同时运行用户级线程的操作系统线程的最大数量。系统调用中可被阻塞的最大线程数并没有限制;可被阻塞的线程数对 GOMAXPROCS 没有影响。这个包的 GOMAXPROCS 函数查询和修改这个最大数限制。 如下示例: func main() { var wg sync.WaitGroup for i := 0;i < 100 ;i++ { wg.Add(1) go func() { http.Get(`https://httpstat.us/200?sleep=10000`) wg.Done() }() } wg.Wait() } 利用追踪工具得到的线程数如下: 由于 Go 优化了系统线程使用,所以当 G 阻塞时,它仍可复用,这就解释了为什么图中的数跟示例代码循环中的数不一致。

14-流程控制语句 阅读更多

带range子句的for语句 numbers1 := []int{1, 2, 3, 4, 5, 6} // 声明并初始化int类型的切片 for i := range numbers1 { // range子句迭代切片汇总的所以元素值 if i == 3 { numbers1[i] |= i } } fmt.Println(numbers1) for语句被执行的时候,range关键字右边(range表达式)的numbers1会被先求值,range表达式的结果可以是数组、数组的指针、切片、字符串、字典或允许接收操作的通道中的某一个,并且结果只能有一个。 对于不同种类的range表达式结果值,for语句的迭代变量的数量可以有所不同。上述切片的的迭代变量可以有两个,切片中的索引值和迭代对应的某一个值。 当range表达式只有一个迭代变量的时候,数组、数组的指针、切片、字符串的元素值都是无处安放的,只能按照从小到大的顺序给出一个个索引值。 注意 numbers2 := [...]int{1, 2, 3, 4, 5, 6} // munbers2是数组,为值类型 maxIndex2 := len(numbers2) - 1 for i, e := range numbers2 { if i == maxIndex2 { numbers2[0] += e } else { numbers2[i+1] += e } } fmt.Println(numbers2) range表达式只会在for语句开始执行时被求值一次,无论后面有多少其他操作 range表达式的求值结果会被复制,被操作的对象是range表达式结果的副本而不是原值 numbers3 := []int{1, 2, 3, 4, 5, 6} // mumber3是切片,为引用类型 maxIndex3 := len(numbers3) - 1 for i, e := range numbers3 { if i == maxIndex2 { numbers3[0] += e } else { numbers3[i+1] += e } } fmt.Println(numbers3) range表达式只会在for语句开始执行时被求值一次,无论后面有多少其他操作 range表达式的求值结果的引用会被复制,被操作的对象是range表达式结果的原值 switch表达式和case表达式的联系 只有switch表达式的结果值与某个case表达式中的任意一个子表达式的结果值相等,该case表达式所属的case子句就会被选中,case子句被选中后,附带在case表达式后边的语句会被执行,其他case子句会被忽略。 value1 := [...]int8{0, 1, 2, 3, 4, 5, 6} switch 1 + 3 { // switch表达式,结果值为无类型常量时,自动转换为此常量值默认的类型 case value1[0], value1[1]: // case表达式,case表达式中的结果值与switch表达式中的结果值类型不同 fmt.Println("0 or 1") case value1[2], value1[3]: fmt.Println("2 or 3") case value1[4], value1[5], value1[6]: fmt.Println("4 or 5 or 6") } 在case子句附带的语句列表中包含fallthrough语句,那么紧挨在它下边的那个case自己附带的语句也会被执行。 因为存在上述判等操作,对switch表达式的结果类型和case表达式的结果类型有要求。 如果switch表达式的结果值为无类型常量,case表达式的结果值为其他类型,无法通过编译 如果case表达式的结果值为无类型常量,且与switch表达式的结果值类型不同,那么会自动转换为switch表达式的结果值的类型 如果表达式的结果类型有某个接口类型,那么一定要检查他们的动态值是否具有可比性。 switch语句对case表达式的约束 结果值为常量的情况下 switch语句在case子句的选择上具有唯一性,因此不允许case表达式结果值存在相等的情况(不论这些结果值相等的子表达式,是否存在于不同的case表达式中)。 // 无法通过编译 value5 := [...]int8{0, 1, 2, 3, 4, 5, 6} switch value5[4] { case value5[0], value5[1], value5[2]: fmt.Println("0 or 1 or 2") case value5[2], value5[3], value5[4]: fmt.Println("2 or 3 or 4") case value5[4], value5[5], value5[6]: fmt.Println("4 or 5 or 6") } // 可以通过编译 value5 := [...]int8{0, 1, 2, 3, 4, 5, 6} switch value5[4] { case value5[0], value5[1], value5[2]: fmt.Println("0 or 1 or 2") case value5[2], value5[3], value5[4]: fmt.Println("2 or 3 or 4") case value5[4], value5[5], value5[6]: fmt.Println("4 or 5 or 6") } 上述第二种方式对类型判断switch语句无效。 类型switch语句 类型switch语句中的case表达式的子表达式,都必须直接由类型字面量表示,而无法通过间接的方式表示。 value6 := interface{}(byte(127)) switch t := value6.(type) { // 类型switch语句 case uint8, uint16: fmt.Println("uint8 or uint16") case byte: // byte为unit8的别名类型,无法通过编译 fmt.Printf("byte") default: fmt.Printf("unsupported type: %T", t) } case子句的编写顺序很重要,最上边的case子句中的子表达式总是会被最先求值,在判等的时候顺序也是这样。因此,如果某些case表达式的结果值有重复,那么位置靠上的case子句总会被选中。

15-错误处理 阅读更多

error类型是一个接口类型,是Go语言內建类型,在这个接口类型的声明中只包含一个方法Error,这个方法不接受任何参数,但是会返回一个string类型的结果。 type error interface { Error() string //返回错误信息的字符串形式 } 使用error类型的方式,通常是在函数声明的结果列表的最后,声明一个error类型的结果,同时在调用这个函数之后,先判断它返回的最后一个结果值是否“不为nil”。 package main import ( "errors" "fmt" ) func echo(request string) (response string, err error) { if request == "" { // 卫述语句 err = errors.New("empty request") return } response = fmt.Sprintf("echo: %s", request) return } func main() { for _, req := range []string{"", "hello!"} { fmt.Printf("request: %s\n", req) resp, err := echo(req) if err != nil { // 卫述语句 fmt.Printf("error: %s\n", err) continue } fmt.Printf("response: %s\n", resp) } } 注意点: 在进行错误处理的时候,经常会用到卫述语句 在生成error类型值的时候用到errors.New函数,这是一种最基本的生成错误值的方式。调用它时传入一个有字符串代表的错误信息,返回一个包含这个错误信息的error类型值。该值的静态类型是error,动态类型是一个errors包中,包级私有的类型*errorString。 errorString类型拥有的一个指针方法实现了errors接口中的Error方法。这个方法被调用后,会原封不动地返回之前传入的错误信息,实际上,error类型值的Error方法就相当于其他类型值的String方法。 在上述例子中,fmt.Printf函数发现被打印的是一个error类型,就会调用它的Error方法。在fmt包中,这类打印函数其实都是这么做的。 当我们通过模板化的方式生成错误信息,并得到错误值时,可以使用fmt.Errorf函数,该函数其实是先调用fmt.Sprintf函数,得到确切的错误信息,在调用errors.New函数,得到该错误信息的error类型值,最后返回该值。 如何判断错误值具体代表哪一类错误 因为error是一个接口类型,所以即使同为error类型的错误值,他们的实际类型也可能不同。 对于类型在已知范围内的一系列错误值在:一般使用类型断言表达式或者switch语句来判断 对于已有相应变量且类型相同的一些错误值,一般直接使用判等操作来判断 对于没有相应变量且类型未知的一系列错误值:只能使用错误信息的字符串表示形式来判断。 类型在已知范围内 类型在已知范围内的错误值是最容易分辨的。如os包中的几个代表错误类型: os.PathError os.LinkError os.SyccallError os/exec.Error 它们的指针类型都是error接口的实现类型,同时它们也都包含了一个名叫Err,类型为error接口类型的代表潜在错误的字段。如果得到一个error类型值,并且知道该值的实际类型肯定是它们中的某一个,那么就用switch语句去判断: func underlyingError(err error) error { switch err := err.(type) { case *os.PathError: return err.Err case *os.LinkError: return err.Err case *os.SyscallError: return err.Err case *exec.Error: return err.Err } return err } // 只要类型不同,就可以使用这种方式来判断,但是如果错误值类型相同,那么这种方式就无效了 已有相应变量且类型相同 如os包中不少错误类型都是通过调用errors.New函数来初始化: os.ErrClosed os.ErrInvalid os.ErrPermission 这几个都是已经定义好的,确切的错误值。os包中的代码有时候会把他们当做潜在的错误值,封装进前面那些错误类型的值中。 如果在操作文件系统的时候得到一个错误值,并且知道该值的潜在错误值肯定是上述值中的某一个,那就可以用普通的switch或者if和判等语句去做判断: printError := func(i int, err error) { // 接受error类型的参数值,该值代表某个文件操作相关的错误 if err == nil { fmt.Println("nil error") return } err = underlyingError(err) switch err { case os.ErrClosed: fmt.Printf("error(closed)[%d]: %s\n", i, err) case os.ErrInvalid: fmt.Printf("error(invalid)[%d]: %s\n", i, err) case os.ErrPermission: fmt.Printf("error(permission)[%d]: %s\n", i, err) } } // 虽然不知道这些错误值的类型范围,但却知道它们或它们潜在的错误值一点在某个已知的os包中定义的值 没有相应变量且类型未知 如果对于一个错误值可能代表的含义知之甚少,那么只能通过它拥有的错误信息去判断了。 我们总是能通过错误值的Error方法拿到它的错误信息。在os包中有os.IsExit、os.IsNotExit和os.IsPermission函数来判断。 如何根据实际情况给予恰当的错误值 构建错误值体系的基本方式有两种: 创建立体的错误类型体系 创建扁平的错误值列表 错误类型体系 在Go语言中实现接口都是非侵入式的,所以可以做的非常灵活。 比如在net包中,有一个名为Error的接口类型,它算是內建接口类型error的一个扩展接口,因为error是net.Error的嵌入接口。 type Error interface { error Timeout() bool // Is the error a timeout? Temporary() bool // Is the error temporary? } net包中有很多错误类型都实现了net.Error接口,如: *net.OpError *net.AddrError net.UnknownNetworkError 等 把错误类型想象成一棵树,内建接口error就是树根,net.Error接口就是一个在根上延伸的第一级非叶子节点。 用类型建立起树形结构的错误体系,用统一字段建立起可追根溯源的链式错误关联。 如果不希望包外代码改动返回错误值的话,一定要小写其中字段的名称首字母。通过暴露某些方法让包外代码有进一步获取错误信息的权限,比如编写一个可以返回包级私有的err字段值的公开方法Err。 错误列表值 相对于立体的错误类型体系,扁平的错误列表值简单很多。只是想要先创建一些代表已知错误的错误值的时候,用扁平化的方式很恰当。

16-panic&recover&defer 阅读更多

运行时恐慌(panic) 这种异常只有在程序运行时才会抛出。 slice := []int{1,2,3} print(slice[3]) // panic: runtime error: index out of range // goroutine 1 [running]: ID为1的用户级线程在此panic被引发时正在运行 // main.main() 用户级线程包装的go函数,在这里是main函数,所以这个是主用户级线程 // /Users/haolin/GeekTime/Golang_Puzzlers/src/puzzlers/article19/q0/demo47.go:5 +0x3d // painc被引起时正在执行的代码 +0x3d 表示此行代码对于其所属的入口程序计数偏移量 // exit status 2 Go语言中因panic而退出的程序一般退出码都是2 Go语言运行时系统,会在执行到这个代码的时候抛出一个索引越界的异常,当panic被抛出后,没有在程序里添加任何保护措施的话,程序(或者代表它的进程)就会在打印出panic的详细信之后终止运行。 上述例子抛出的异常是runtime包中抛出的panic,在这个panic中,包含一个runtime.Error接口类型的值,runtime.Error接口内嵌了error接口,并做了一点扩展。在panic右边的内容,就是这个panic包含的runtime.Error类型值的字符串表示形式。 从panic被引发到程序终止运行的大致过程 函数中的某行代码有意或无意地引发了一个panic 初始的panic详情会被建立起来 该程序的控制权会立即从此代码转移至调用起所属函数的那个代码行上(也就是调用栈中的上一级) 此行代码所属函数的执行随即被终止 控制权立即转移至再上一级的调用代码处(控制权一级一级沿着调用栈的反方向传播至顶端,我们编写的最外层函数) 对于其他用户级线程,最外层函数是go函数,对于主用户级线程,最外层函数是main函数 控制权最终被Go运行时系统收回 随后程序崩溃并终止运行,承载程序这次运行的进程会随之死亡并消失 在这个控制权传播的过程中,panic详情会被积累和完善,并会在程序终止之前打印出来。 Go语言的内建函数panic是专门用于引发panic的,panic函数使程序开发者可以在程序运行期间报告异常。这与从函数返回错误值的意义是完全不同的,当我们的函数返回一个非nil的错误值时,函数的调用方有权选择不处理,并且不处理的后果往往是不致命的(即不至于程序僵死或崩溃)。 当一个panic发生时,如果不施加任何保护措施,那么导致的直接后果就是程序崩溃,这是致命的。 panic只能在程序运行期间抛出的程序异常,它的详情会在控制权传播的过程中,被逐渐地积累和完善,并且,控制权会一级一级地沿着调用栈的反方向传播至顶端。 让panic包含一个值 如果一个panic是无意间引发的,那么其中的值只能由Go语言运行时系统给定 如果使用panic函数有意地引发一个panic,那么可以自行指定其包含的值 在调用panic函数时,把某个值作为参数传递给该函数,即可自行指定其包含的值。由于panic函数的唯一一个参数是空接口(interface)类型,从语法上,可以接收任何类型的值。但是,最好传入error类型的错误值,或者其他可被有效序列化(有效序列化指的是更易读,且易于表示形式转换)的值。 在fmt包中的各种打印函数来说,error类型的值Error方法与其他类型的String方法是等价的,它们的唯一结果都是String类型。通过占位符%s输出它们的字符串表示形式。一旦程序异常,就应该把异常信息记录到程序日志中。 所以如果某个值可能会被记录到日志中,就应该给它关联String方法,如果是error类型,就应该关联Error方法。 对panic施加保护避免程序崩溃 Go语言内建的recover专用于恢复panic,平息运行时恐慌。recover函数无需任何参数,并且会返回一个空接口类型的值。如果用法正确,这个值实际上就是即将恢复的panic包含的值。如果这个panic是我们调用panic函数而引发的,那么该值同时会是我们此次调用panic函数时传入的参数值副本。 package main import ( "fmt" "errors" ) func main() { fmt.Println("Enter function main.") // 引发 panic。 panic(errors.New("something wrong")) // 手动调用panic引发运行时恐慌 p := recover() fmt.Printf("panic: %s\n", p) // 手动调用recover恢复panic fmt.Println("Exit function main.") } recover函数的调用没有任何作用,甚至都没有机会执行,因为panic一旦发生,控制权就会迅速地沿着调用栈的反方向传播,所以在panic函数调用之后的代码,根本就没有执行的机会。 如果我们在调用recover函数时没有发生panic,那么该函数不会做任何事情,并且只会返回一个nil。正确调用recover函数需要配合defer语句一起使用。 defer语句 defer语句是被用来延迟执行代码的,延迟到该语句所在的函数即将执行结束的那一刻,无论结束执行的原因是什么。有一些调用表达式不能出现在defer语句中: 针对Go语言内建函数的调用表达式 针对unsafe包中的函数的调用表达式 在defer语句中,被调用的函数可以是有名称的,也可以是匿名的,把这里的函数叫做defer函数或者延迟函数,注意,被延迟执行的是defer函数,而不是defer语句。 无论函数结束执行的原因是什么,其中defer函数调用都会在它即将结束执行的那一刻执行,即使执行结束的原因是一个panic也会是这样。因此,需要连用defer语句和recover函数,才能恢复一个已经发生的panic。 package main import ( "fmt" "errors" ) func main() { fmt.Println("Enter function main.") defer func(){ fmt.Println("Enter defer function.") // p!=nil 判断确实发生了panic if p := recover(); p != nil { fmt.Printf("panic: %s\n", p) } fmt.Println("Exit defer function.") }() // 引发 panic。 panic(errors.New("something wrong")) fmt.Println("Exit function main.") } 注意defer语句的位置,尽量写在函数体的开始处,因为在引发panic的语句之后的所有语句,都没有任何执行的机会。 一个函数中有多个defer语句,defer函数的执行顺序 在一个函数中,defer函数调用的执行顺序与它们分别所属的defer语句的出现顺序(执行顺序)完全相反。当一个函数即将结束执行时,其中写在最下面的defer函数调用会最先执行,其次是写在它上边、与它距离最近的defer函数调用,以此类推,最上边的defer函数调用最后一个执行。 如果一个for语句中包含一个defer语句,那么defer语句的执行次数,取决于循环的迭代次数,并且同一个defer语句被执行一次,其中的defer函数调用就会产生一次,而且这些函数调用同样不会被立即执行。 defer语句执行时发生的事情 在defer语句每次被执行的时候,Go语言会把它携带的defer函数以及其参数值另行存储到一个先进先出队列中。这个队列与defer语句所属的函数是对应的,相当于一个栈。 需要执行某个函数中的defer函数调用的时候,Go语言会先拿到对应的队列,然后从该队列中一个一个地取出defer函数及其参数值,并逐个执行调用。

17-sync 阅读更多

0.1. sync.Mutex和sync.RWMutex 0.1.1. 竞态条件、临界区和同步工具 0.1.2. 使用互斥锁注意事项 0.1.3. 传递互斥锁 0.1.4. 读写锁与互斥锁的区别 0.1.5. 总结 0.2. sync.Cond 0.2.1. 条件变量与互斥锁 0.2.2. Wait方法 0.2.3. Signal方法与Broadcast方法的区别 0.3. sync.WaitGroup 和 sync.Once 0.3.1. 注意点 0.3.2. sync.Once类型值的Do方法如何保证只执行参数函数一次 0.3.2.1. Do方法在功能方面的特点 0.3.3. 小结 0.4. sync.Pool 0.4.1. 例子 0.4.2. 临时对象的销毁 0.4.3. 临时对象池的数据结构 0.4.4. 临时对象池如何存取值 0.5. sync.Map 0.5.1. sync.Map对键类型的要求 0.5.2. 保证sync.Map中键值的类型正确性 0.5.2.1. 方案一 0.5.2.2. 方案二 0.5.3. sync.Map如何尽量避免使用锁 0.1. sync.Mutex和sync.RWMutex 0.1.1. 竞态条件、临界区和同步工具 Go语言宣扬“用通信的方式共享数据”,但是,通过共享数据的方式来传递信息和协调线程运行的做法其实更加主流,大部分现代编程语言都使用后一种方式作为并发编程的解决方案。 一旦数据被多个线程共享,很可能会产生争用和冲突的情况,这种情况称为竞态条件(race condition),这往往会破坏共享数据的一致性。 共享数据的一致性代表着:多个线程对共享数据的操作总是可以达到它们各自预期的效果。 同步的用途有两个: 避免多个线程,在同一时刻操作同一个数据块 协调多个线程,避免它们在同一时刻执行同一个代码块 这些数据块和代码块的背后都隐含着一种或多种资源(如存储资源,计算资源、I/O资源、网络资源等),把它们看成是共享资源,同步其实就是在控制多个线程对共享资源的访问。 如果某个共享资源的访问,在同一时刻只能有一个线程进行,那么多个并发线程对该共享资源的访问是完全串行的。只要一个代码块需要实现对共享资源的串行化访问,就可以被视为一个临界区(critical section),由于要访问到资源而必须进入的那个区域。 如果针对同一个共享资源,这样的代码块有多个,那么它们成为相关临界区。 它们可以是一个内含了共享数据的结构体及其方法, 也可以是操作同一块共享数据的多个函数。 临界区总是需要受到保护,否则就会产生竞态条件,施加保护的重要手段之一,就是使用实现了某种同步机制的工具,称为同步工具。 在Go语言中,可供选择的同步工具不少,最重要最常用的同步工具是互斥量(mutual exclusion,简称mutex)。sync包中的Mutex就是与其对应的类型,该类型的值可以被称为互斥量或者互斥锁。 一个互斥锁可以被用来包含一个临界区或者一组相关临界区,通过它来保证在同一时刻只有一个goroutine处于该临界区内。每当有goroutine想进入临界区时,都需要先对它进行锁定,离开时要及时进行解锁。 锁定操作可以通过调用互斥锁的Lock方法实现 解锁操作可以通过调用互斥锁的Unlock方法实现 mu.Lock() _, err := writer.Write([]byte(data)) if err != nil { log.Printf("error:%s[%d]",err,id) } mu.UnLock() 0.1.2. 使用互斥锁注意事项 不要重复锁定互斥锁: 对于一个已经锁定的互斥锁进行锁定,会立即阻塞当前的goroutine,这个goroutine执行的流程会一直停滞在调用该互斥锁的Lock方法的那行代码上。直到互斥锁的Unlock方法被调用,并且这里的锁定操作成功之后,临界区的代码才会执行。 不要忘记解锁互斥锁,必要时使用defer语句 可以避免出现重复锁定。因为忘记解锁会使得其他goroutine无法进入到互斥锁保护的临界区中,轻则功能失效,重则死锁崩溃。程序的流程可以分叉也可以被中断,所以一个流程在锁定某个互斥锁之后,紧跟着defer语句进行解锁是比较稳妥的。 不要对尚未锁定或者已解锁的互斥锁解锁 解锁未锁定的互斥锁会立即引起panic。与死锁的panic一样,无法被恢复。因此对于每一个锁定操作有且只有一个对应的解锁操作。 不要在多个函数之间直接传递互斥锁 死锁,当前程序中的主goroutine,以及启用的那些goroutine都已经被阻塞,这些goroutine可以被统称为用户级的goroutine,这就相当于整个程序都已经停滞不前了。 Go语言运行时系统不允许这种情况出现,当发现所以用户级goroutine都处于等待会抛出如下panic: fatal error: all goroutines are asleep - deadlock! Go语言运行时系统自行抛出的panic都属于致命错误,无法被恢复,调用recover函数对它们起不到任何作用,程序死锁,必然崩溃。 当每个互斥锁都只保护一个临界区或者一组相关临界区可以有效避免死锁。 0.1.3. 传递互斥锁 Go语言中的互斥锁是开箱即用的,声明一个sync.Mutex类型(该类型是一个结构体类型,属于值类型)的变量就可以直接使用了。 对于值类型的操作,把它传给一个函数,将它从函数中返回,把它赋给其他变量,让它进入某个通道都会导致它的副本的产生。原值与副本、副本与副本之间都是完全独立的,它们都是不同的互斥锁。 如果把一个互斥锁作为参数传给了一个函数,那么在这个函数中对传入的锁的所有操作,都不会对存在于该函数之外的那个原锁产生任何的影响。 0.1.4. 读写锁与互斥锁的区别 读写锁是读/写互斥锁的简称,在Go语言中,读写锁由sync.RWMutex类型的值代表,也是开箱即用。 读写锁把读操作和写操作区别对待,可以对这两种操作施加不同的保护。相比于互斥锁,读写锁实现更加细粒度的访问控制。一个读写锁中包含两个锁: 写锁:sync.RWMutex类型中的Lock方法和Unlock方法分别用于对写锁进行锁定和解锁 读锁:sync.RWMutex类型中的RLock方法和RUnlock方法分别用于对于读锁进行锁定和解锁 对于同一个读写锁来说,有如下规则: 在写锁已被锁定的情况下,再试图锁定写锁,会阻塞当前goroutine 在写锁已被锁定的情况下,再试图锁定读锁,会阻塞当前goroutine 在读锁已被锁定的情况下,再试图锁定写锁,会阻塞当前的goroutine 在读锁已被锁定的情况下,再试图锁定读锁,不会阻塞当前goroutine 也就是说,对于某个受到读写锁保护的共享资源: 多个写操作不能同时进行 读操作和写操作不能同时进行 多个读操作可以同时进行 通常不能同时进行的操作称为互斥操作。 对于写锁进行解锁操作,会唤醒所有因试图锁定读锁而被阻塞的goroutine,通常会使它们都成功完成对读锁的锁定。 对于读锁进行解锁操作,只会在没有其他读锁锁定的前提下,唤醒因试图锁定写锁而被阻塞的goroutine,并且最终只会有一个被唤醒的goroutine(等待时间最长的那个)成功完成对写锁的锁定,其他goroutine继续等待。 读写锁中对写操作之间的互斥是通过内含的一个互斥锁实现的,在Go语言中,读写锁是互斥锁的一种扩展。 0.1.5. 总结 互斥锁常常被用来保证多个goroutine并发访问同一个共享资源时的完全串行。 不要忘记锁定或忘记解锁,这会导致goroutine的阻塞甚至死锁 不要传递互斥锁,这会产生它的副本,从而产生奇异或者导致互斥失效 让每一个互斥锁都只包含一个临界区,或一组相关临界区 不要解锁未锁定的锁,会引发不可恢复的panic 0.2. sync.Cond 0.2.1. 条件变量与互斥锁 条件变量是另一个同步工具,它是基于互斥锁的,它不是用来保护临界区和共享资源的,而是用于协调想要访问共享资源的那些线程。当共享资源的状态发生变化时,它可以被用来通知互斥锁阻塞的线程。 条件变量的初始化离不开互斥锁,并且它的方法有的也是基于互斥锁的。 条件变量有三个方法: 等待通知(Wait):在互斥锁保护下进行 单发通知(signal):在互斥锁解锁后进行 广播通知(broadcast):在互斥锁解锁后进行 var mailbox uint8 var lock sync.RWMutex // 读写锁 sendCond := sync.NewCond(&lock) // 条件变量 recvCond := sync.NewCond(lock.RLocker()) // 条件变量 // func NewCond(l Locker) *Cond 返回带有锁的条件变量的指针值 // goroutine 1 lock.Lock() // 锁定写锁 for mailbox == 1 { // 如果有情报就等待 sendCond.Wait() // 解锁写锁,加入通知队列,阻塞当前代码行 } mailbox = 1 // 如果没有情报就放入情报。1表示放入情报 lock.Unlock() // 解锁写锁 recvCond.Signal() // 发起通知情报已经放好 // goroutine 2 lock.RLock() // 锁定读锁 for mailbox == 0 { // 如果没有情报就等待 recvCond.Wait() // 解锁读锁,加入通知队列,阻塞当前代码行 } mailbox = 0 // 如果有情报就取出情报。0表示取出情报 lock.RUnlock() // 解锁读锁 sendCond.Signal() // 发起通知情报已经取走 只要条件不满足,就会调用wait方法,需要发起通知就调用signal方法。使用条件变量实现单向通知,双向通知需要两个条件变量,这是条件变量的基本使用规则。 0.2.2. Wait方法 条件变量的Wait方法主要做了四件事情: 把调用它的goroutine(即当前goroutine)加入到当前条件变量的通知队列中 解锁当前的条件变量基于的那个互斥锁(条件变量的Wait方法在阻塞当前goroutine前,会解锁它基于的互斥锁,所以在调用Wait之前,必须先锁定互斥锁,否则调用Wait方法会引发不可恢复的painc。) 如果Wait方法不先解锁互斥锁,那么只会有两种后果,不是当前程序因panic而崩溃,就是相关的goroutine全面阻塞。 让当前的goroutine处于等待状态,等到通知到来时再决定是否唤醒它,此时,这个goroutine就会阻塞在调用这个Wait方法的那行代码上 如果通知到来并且决定唤醒这个goroutine,那么就在唤醒它之后重新锁定当前条件变量基于的互斥锁,自此之后,当前的goroutine就会继续执行后面的代码了 需要使用for循环包裹Wait方法来多次检查共享资源的状态,因为当一个goroutine收到通知被唤醒,但却发现共享组员的状态依然不符合要求,那么应该再次调用条件变量Wait方法,并继续等待下一次通知的到来。例如下面的情况: 多个goroutine等待共享资源的同一种状态。 共享资源有多种状态,单一的结果不可能满足所以goroutine的条件。 某些多CPU核心的计算机系统中,没有收到条件变量的通知,调用其Wait方法的goroutine也可能会被唤醒,这是计算机硬件层面决定的,即使是操作系统本身提供的条件变量也会如此。 综上,在包裹条件变量的Wait方法时,总是应该使用for语句,因为等待通知而被阻塞的goroutine可能会在共享资源的状态不满足其要求的情况下被唤醒。 0.2.3. Signal方法与Broadcast方法的区别 共同点:都是被用来发送通知的 不同点: 前者的通知只会唤醒一个因此而等待的goroutine 条件变量的Wait方法总是会把当前goroutine添加到通知队列的队尾,而signal方法总会从通知队列的队首开始,查找可被唤醒的goroutine。因此会唤醒最早等待的那一个。 后者的通知会唤醒所有为此等待的goroutine 这两个方法的行为决定了它们的使用场景。 如果确定只有一个goroutine在等待,或者只许唤醒任意一个goroutine就可以满足要求,那么使用Signal方法。 否则,使用Broadcast方法,只要设置好各个goroutine所期望的共享资源状态即可。 与Wait方法不同,Signal方法和Broadcast方法并不需要在互斥锁的保护下进行。最好在解锁条件变量基于的互斥锁之后在调用它们。这更有利于程序的运行效率。 条件变量的通知具有即时性,即在发送通知的时候没有goroutine为此等待,那么该通知就会被直接丢弃。在这之后才开始等待的goroutine只可能被后面的通知唤醒。 通过对互斥锁的合理使用,可以是一个goroutine在执行临界区中的代码时,不被其他的goroutine打扰。不过,虽然不会被打扰,但是它仍然可能会被中断(interruption)。 0.3. sync.WaitGroup 和 sync.Once 使用通道进行多goroutine协作:声明一个通道,使它的容量与手动启动的goroutine的数量相同,之后再利用这个通道,让主goroutine等待其他goroutine的运行结束。 func coordinateWithChan() { sign := make(chan struct{}, 2) num := int32(0) fmt.Printf("The number: %d [with chan struct{}]\n", num) max := int32(10) go addNum(&num, 1, max, func() { sign <- struct{}{} }) go addNum(&num, 2, max, func() { sign <- struct{}{} }) <-sign <-sign } 以上操作,略显丑陋。 使用sync包的WaitGroup类型,它比通道更加适合这种一对多的goroutine协作流程。 sync.WaitGroup开箱即用,并发安全,同样的它一旦真正被使用,就不能再被复制。 WaitGroup有三个指针方法: Add:可以想象在该类型中有一个计数器,默认值为0,可以调用该值类型的Add方法来增加或者减少这个计数器的值。一般用来记录等待的goroutine的数量。 Done:对计数器中的值进行减操作,可以在需要等待的goroutine中,通过defer语句调用它。 Wait:阻塞当前的goroutine,直到其所属值中的计数器归零。如果在该方法调用的时候,计数器的值已经是0,那么就不做任何事情。 将上面代码修改为WaitGroup版本: func coordinateWithWaitGroup() { var wg sync.WaitGroup wg.Add(2) num := int32(0) fmt.Printf("The number: %d [with sync.WaitGroup]\n", num) max := int32(10) go addNum(&num, 3, max, wg.Done) go addNum(&num, 4, max, wg.Done) wg.Wait() } 0.3.1. 注意点 sync.WaitGroup类型值中计数器的值不能小于0。如果小于0,会引发一个panic。不适当的调用Done方法和Add方法都会引起这个问题,因为在Add方法中可以传入一个负数。 如果同时调用Add方法和Wait方法,假设在两个goroutine中,分别调用这两个方法,那么就可能会让这个Add方法抛出panic。这种情况不容易复现,虽然WaitGroup值本身并不需要初始化,但是尽早增加其计数器的值,是非常有必要的。 WaitGroup值是可以被复用的,但是需要保证其计数周期的完整性。在WaitGroup值的生命周期中,它可以经历任意多个计数周期。但是只有在它走完当前的计数周期之后,才能够开始下一个计数周期。 计数周期:WaitGroup值中的计数器值由0变为某个正整数,然后经过一系列的变化,最终由某个正整数又变回0。 因此,如果某个WaitGroup值的Wait方法在某个计数周期中被调用,会立即阻塞当前的goroutine,直到这个计数周期完成,在这种情况下,该值的下一个计数周期必须等到Wait方法执行结束之后,才能够开始。 如果在一个此类值的Wait方法被执行期间,跨越了两个计数周期,会引发一个panic。举个例子: 在当前goroutine因调用WaitGroup值的Wait方法,而被阻塞的时候 另一个goroutine调用该值的Done方法,并使计数器的值变为0 这会唤醒当前的goroutine,并使它试图继续执行Wait方法中其余的代码 这时又有一个goroutine调用了它的Add方法,并让计数器的值又从0变成了某个正整数 此时,这里的Wait方法就会立即抛出一个panic WaitGroup使用禁忌:不要把增加计数器值的操作和调用Wait方法的代码,放在不同的goroutine中执行。杜绝同一个WaitGroup值的两种操作的并发执行。 0.3.2. sync.Once类型值的Do方法如何保证只执行参数函数一次 与sync.WaitGroup类型一样,sync.Once类型也属于结构体类型,同样也是开箱即用和并发安全的。由于这个类型中包含了一个sync.Mutex类型的字段,所以复制该类型的值也会导致功能的失效。 Once类型的Do方法只接受一个参数,这个参数的类型必须是func(),即:无参数声明和结果声明的函数。该方法的功能并不是对每一种参数函数都只执行一次,而是只执行首次被调用时传入的那个函数,并且之后不会再执行任何参数函数。如果有多个只需执行一次的函数,为它们每个都分配一个sync.Once类型的值。 Once类型中还有一个叫done的unit32类型的字段,它的作用是记录其所属值的Do方法被调用的次数,该值只能是0或者1。一旦Do方法的首次调用完成,它的值就会从0变成1。 done字段虽然只有0或者1,但是使用了四字节的uint32类型: 对这个字段的操作必须是原子的,Do方法在一开始就会通过调用atomic.LoadUint32函数来获取该字段的值,并且一旦发现该值为1,就直接返回。初步保证了Do方法,只会执行首次被调用时传入的函数。 如果两个goroutine都调用了同一个新的Once的Do方法,几乎同时执行条件判断代码,那么会因为判断结果为false而继续执行Do方法中剩余的代码。 所以在条件判断之后,Do方法会立即锁定其所属值中的那个sync.Mutex类型的字段m,然后,它会在临界区中再次检查done字段的值,并且仅在条件满足时,才会去调用参数函数,以及用原子操作把done的值变为1。 0.3.2.1. Do方法在功能方面的特点 Do方法只会在参数函数执行结束之后把done字段的值变为1,因此,如果参数函数的执行需要很长时间或者根本就不会结束(比如执行一些守护任务),那么就有可能会导致相关goroutine的阻塞。 Do方法在参数函数执行结束后,对done字段的赋值用的是原子操作,并且,这一操作是被挂在defer语句中的,因此,不论参数函数的执行会以怎样的方式结束,done字段的值都会变为1。即使这个参数函数没有执行成功(引发了一个panic),我们也无法使用同一个once值重新执行它。如果需要为参数函数的执行设定重试机制,要考虑Once值的适时替换问题。 0.3.3. 小结 sync包中的WaitGroup类型和Once类型都是非常易用的同步工具。它们都是开箱即用和并发安全的。 利用WaitGroup值,可以方便地实现一对多的goroutine协作流程,即:一个分发子任务的goroutine和多个执行子任务的goroutine,共同来完成一个较大的任务。 使用WaitGroup值的时候,一定要注意,千万不能让其中的计数器的值小于0,否则就会引发panic。我们最好用“先统一Add,再并发Done,最后Wait”这种标准方式,来使用WaitGroup值。尤其不要在调用Wait方法,同时,并发地通过调用Add方法去增加其计数器的值,因为这也有可能引发panic。 Once值的使用比WaitGroup值更简单,只有一个Do方法,同一个Once值的Do方法永远只会执行第一次被调用时传入的参数函数,不论这个函数的执行会以怎么样的方式结束。 只要传入某个Do方法的参数函数没有结束执行,任何只会调用该方法的goroutine就会被阻塞。只有在这个参数函数执行结束后,那些goroutine才会逐一被唤醒。 Once类型使用互斥锁和原子操作实现了功能,而WaitGroup类型中只用到了原子操作。它们都是更高级的同步工具,基于基本的通用工具,实现了某一种特定的功能。sync包中的其他高级同步工具,都是这样实现的。 0.4. sync.Pool sync.Pool类型(结构体类型,它的值被真正使用之后,就不应再被复制了)被称为临时对象存储池,它的值可以被用来存储临时的对象。 临时对象的意思是: 不需要持久使用的某一类值,这类值对于程序来说可有可无,如果有的话会明显更好 它的创建和销毁可以在任何时候发生,并且完全不会影响到程序的功能 它们无需被区分,其中的任何一个值都可以替代另一个 如果某类值完全满足上述条件,就可以把它们存储到临时对象池中。 可以把临时对象池当作针对某种数据的缓存来用,这是临时对象池最主要的用途。 sync.Pool类型只有两个方法: func (p *Pool) Put(x interface{}) func (p *Pool) Get() interface{} Put:用于在当前的池中存放临时对象,它接收一个interface类型的参数 Get:用于从当前的池中获取临时对象,它返回一个interface类型的值 Get可能会从当前池中删除掉任何一个值,然后把这个值作为结果返回。如果此时池中没有任何值,那么这个方法就会使用当前池的New字段创建一个值,并直接将其返回。 sync.Pool类型的New字段代表着创建临时对象的函数,它的类型是没有参数但有唯一结果的函数类型: type Pool struct { // 这个函数是Get方法最后的临时对象获取手段 // 该函数的结果值并不会存入当前的临时对象中 // 而是直接返回给Get方法的调用方 New func() interface{} // contains filtered or unexported fields } 这里New字段的实际值需要我们在初始化临时对象池的时候给定。否则,在调用它的Get方法的时候可能会得到nil。sync.Pool类型并非开箱即用,这个类型只有这一个公开字段。 0.4.1. 例子 标准库fmt中使用sync.Pool类型,这个包会创建一个用于缓存某类临时对象的sync.Pool类型值,并将这个值赋给ppFree变量,这个临时对象可以用于识别、格式化和暂存需要打印的内容: var ppFree = sync.Pool{ New: func() interface{} { return new(pp) }, } // ppFree的New字段在被调用的时候,总是会返回一个全新的pp类型值的指针(即临时对象) // 这保证了ppFree的Get方法总能返回一个可以包含需要打印内容的值 // pp类型是fmt包中的私有类型,它的每一个值在这里都是独立、平等、可重用的 这些临时对象既互不干扰,又不会受到外部状态的影响。它们几乎只针对某个需要打印内容的缓冲区。由于fmt包中的代码在真正使用这些临时对象之前,总是会对其进行重置,所以它们并不在意取到哪个临时对象。这就是临时对象的平等性的具体体现。 这些代码在使用完临时对象后,都会先抹掉其中已缓冲的内容,然后再把它存放到ppFree中。这样就为重用这类临时对象做好了准备。 fmt.Println、fmt.Printf等打印函数都是这样使用ppFree,以及其中的临时对象。因此程序同时执行很多打印函数调用的时候,ppFree可以及时地把它缓存的临时对象提供给它们,以加快执行的速度。当程序在一段时间内不再执行打印函数,ppFree中的临时对象有能够被及时地清理(垃圾回收期,在每次开始执行前,对所有创建的临时对象池中的值进行全面地清除),以节省内存空间。在这个维度上,临时对象池可以帮助程序实现可伸缩性。 0.4.2. 临时对象的销毁 sync包在被初始化的时候,会向Go语言运行时系统注册一个函数(池清理函数),这个函数的功能就是清除所有已创建的临时对象池中的值,这样Go语言运行时系统在执行垃圾回收之前会先执行池清理函数。 sync包中有一个包级私有的全局变量(池汇总列表),这个变量代表了当前的程序中使用的所有临时对象池的汇总,它是元素类型为*sync.Pool的切片。 在一个临时对象池的Put方法和Get方法第一次被调用的时候,这个池就会被添加到池汇总列表中。因此,池清理函数总是能访问到所有正在被真正使用的临时对象池。即: 池清理函数会遍历池汇总列表,对于其中的每一个临时对象池,都会先将池中所有的私有临时对象和共享临时对象列表置为nil 然后再把这个池中的所有本地池列表都销毁掉 最后,池清理函数会把池汇总列表重置为空的切片,这样池中存储的临时对象就全部被清除干净了 如果临时对象池一外地的代码再无对它们的引用,那么在稍后的垃圾回收过程中,这些临时对象就会被当做垃圾销毁掉它们占用的内存空间就会被回收已备他用 0.4.3. 临时对象池的数据结构 在临时对象池中有一个多层的数据结构,因为它的存在使得临时对象池能够非常高效地存储大量的值。这个数据结构的顶层,称为本地池列表(它是一个数组),这个列表的长度总是与Go语言调度器中P的数量相同。 Go语言调度器中的P是processor的缩写,指的是一种可以承载若干个G(goroutine),且能够使这些G适时地与M(machine,即系统级线程)进行对接,并得到真正运行的中介。因为P 的存在,G和M才能够进行高校、灵活的配对,从而实现强大的并发编程模型。 P存在的一个重要原因是为了分散并发程序的执行压力, 让临时对象池中的本地池列表的长度与P的数量相等的主要原因就是分散压力。 这里的压力包括两方面:存储和性能。 本地池列表中的每个本地池包含三个字段: private:存储私有临时对象 shared:共享临时对象列表 sync.Mutex类型的嵌入字段 每个本地池都对应着一个P,以为你一个goroutine要真正运行起来必须先与某个P产生关联。在程序调用临时对象池的Put或Get方法的时候,总会先试图从该临时对象池的本地池列表中,获取与之对应的本地池,依据的就是与当前goroutine关联的那个P的ID。 也就是说,一个临时对象池的Put方法或Get方法或获取到哪一个本地池,完全取决于调用它的代码所在的goroutine关联的那个P。 0.4.4. 临时对象池如何存取值 Put: 总会先试图把新的临时对象,存储到对应的本地池的private字段中,以便在后面获取临时对象的时候,可以快速地拿到一个可用的值。 只有当这个private字段已经存在有某个值时,该方法才会去访问本地池的shared字段。 Get: 总会先试图从对应的本地池的private字段处获取一个临时对象 当private字段的值为nil时,它才会去访问本地池的shared字段 一个本地池的shared字段原则上可以被任何goroutine中的代码访问,不论这个goroutine管理的是哪一个P,因此shard也称为共享临时对象列表。因为shared字段是共享的,所以必须收到互斥锁的保护。 一个本地池的private字段只能被与之对应的那个P所关联的goroutine中的代码访问到,所以它是P级私有的。 本地池本身拥有互斥锁功能,即它嵌入的那个sync.Mutex类型的字段。Put方法会在互斥锁的保护下,把新的临时对象追加到共享临时对象列表的末尾。Get方法会在互斥锁的保护下,试图把共享临时对象列表中的最后一个元素值取出并作为结果。 这里的共享临时对象列表也可能是空的,这可能是由于这个本地池中的所有临时对象都已经被取走了,也可能是当前的临时对象池刚被清理过。 无论什么原因,Get方法都会去访问当前临时对象中的所有本地池,它会之歌搜索它的共享临时对象列表。发现某个共享临时对象列表中包含元素值,它就会把该列表的最后一个元素取出来并作为结果返回。 即使这样也可能无法拿到一个可用的临时对象,Get会调用创建临时对象的那个函数。这个函数由临时对象池的New字段代表,并且需要在初始化临时对象池的时候给定。如果这个字段为nil,那么Get方法此时也只能返回nil。 0.5. sync.Map Go语言自带的字典类型map不是并发安全的,也就是说,在同一时间段内,让不同goroutine中的代码,对同一个字典进行读写操作是不安全的。字典值本身可能会因为这些操作而产生混乱,相关程序也可能会因此发生不可预知的问题。 使用sync.Mutex或者sync.RWMutex,在加上原生的map就可以轻松实现并发安全的字典。 Go 1.9中发布了并发安全的字典类型sync.Map。这个字典类型提供了一些常用的键值存取操作方法,并保证了这些操作的并发安全,同时,它的存、取、删等操作都可以基本保证常数时间内执行完毕。它的算法复杂的与map类型一样都是0(1)。 与单纯使用原生map和互斥锁相比,sync.Map可以显著地减少锁的争用。sync.Map本身虽然也用到了锁,但是,它其实在尽可能地避免使用锁。因为使用锁就意味着把一些并发的操作强制串行化,这会降低程序的性能,尤其是在计算机拥有多个CPU核心的情况下。因此能用原子操作(原子操作的局限性,只能对一些基本的数据类型提供支持)的情况下就不要用锁。 无论在何种场景下使用sync.Map,都需要注意它与原生map明显不同,它只是标准库中的一员,而不是语言层面的东西。编译器并不会对它的键和值进行特殊的类型检查。 sync.Map所有的方法涉及的键和值的类型都是interface{},这意味着可以包罗万象,所以必须在程序中自行保证它的键类型和值类型的正确性。 0.5.1. sync.Map对键类型的要求 键的实际类型不能是函数类型、字段类型和切片类型。 Go语言原生字典的键类型也不能是函数类型、字典类型和切片类型。 sync.Map内部的存储介质就是原生字典,又因为原生字典的键类型也是interface{},所以绝对不能带这任何实际类型为函数类型、字典类型或切片类型的键值去操作sync.Map。因为这些键值的实际类型只有在程序运行期间才能确定,所以Go语言编译器是无法在编译期间对它们进行检查的,不正确的键值实际类型会引发panic。 所以在每次操作sync.Map的时候,显式地检查键值的实际类型。更好的操作是针对同一个sync.Map的存、取、删操作都集中起来,然后统一编写检查代码。或者把sync.Map封装在一个结构体中也是一个不错的选择。 必须保证键的类型是可比较的(可判等的),实在拿不准,可以: 使用reflect.Typeof()函数得到一个键值对应的反射类型值(即:reflect.Type类型的值) 调用这个值的Comparable方法得到确切的判断结果 0.5.2. 保证sync.Map中键值的类型正确性 使用类型断言表达式或者反射操作来保证它们的类型正确性。 0.5.2.1. 方案一 让sync.Map只能存储某个特定类型的键。一旦确定好了键的类型,就可以在存、取、删操作的时候,使用类型断言表达式去对键的类型做检查。 一般情况下,这样的检查并不繁琐,如果把sync.Map封装在一个结构体类型里就更方便了,这样完全可以使用Go语言编译器帮助进行类型检查,如下所示: // 键类型为int,值类型为string // 在这个结构体中,只有sync.Map类型的字段m type IntStrMap struct { m sync.Map } func (iMap *IntStrMap) Delete(key int) { iMap.m.Delete(key) } func (iMap *IntStrMap) Load(key int) (value string, ok bool) { v, ok := iMap.m.Load(key) if v != nil { value = v.(string) } return } func (iMap *IntStrMap) LoadOrStore(key int, value string) (actual string, loaded bool) { a, loaded := iMap.m.LoadOrStore(key, value) actual = a.(string) return } func (iMap *IntStrMap) Range(f func(key int, value string) bool) { f1 := func(key, value interface{}) bool { return f(key.(int), value.(string)) } iMap.m.Range(f1) } func (iMap *IntStrMap) Store(key int, value string) { iMap.m.Store(key, value) } 这样在这些方法操作键值的时候,就不再需要进行类型检查,也不用担心类型会不正确。 因此,在确定键和值的具体类型的情况下,可以利用Go语言编译器去做类型检查,并用类型断言表达式作为辅助。 0.5.2.2. 方案二 封装的结构体类型的所有方法都可以与sync.Map类型的方法完全一致(包括方法名称和方法签名)。不过在这些方法中需要添加一些类型检查的代码。这样sync.Map的键和值类型必须在初始化的时候就完全确定,并且必须先保证键的类型是可比较的。 所以封装的结构体如下: // 可自定义键和值类型的sync.Map type ConcurrentMap struct { m sync.Map // 键和值都是反射类型,该类型可以代表Go语言的任何数据类型 // 这个类型值容易获得,通过调用reflect.Typeof函数并把样本值传入即可 keyType reflect.Type valueType reflect.Type } func (cMap *ConcurrentMap) Load(key interface{}) (value interface{}, ok bool) { // 将一个接口类型值传入reflect.Typeof函数,就可以得到这个值的实际类型对应的反射类型值 if reflect.TypeOf(key) != cMap.keyType { return } return cMap.m.Load(key) } func (cMap *ConcurrentMap) Store(key, value interface{}) { // 当key和value的实际类型不符合要求时,store方法会立即引发panic // 因为store方法没有结果声明,所以在参数值有问题的时候,无法通过比较平和的方式告知调用方 if reflect.TypeOf(key) != cMap.keyType { panic(fmt.Errorf("wrong key type: %v", reflect.TypeOf(key))) } if reflect.TypeOf(value) != cMap.valueType { panic(fmt.Errorf("wrong value type: %v", reflect.TypeOf(value))) } cMap.m.Store(key, value) } // 也可以为store方法添加一个error类型的结果,在发现参数值类型不正确的时候, // 让它直接返回响应的error类型值,而不是引发panic,实际中可根据应用场景进行改进和优化 方案一: 适合可以完全确定键和值类型的情况,可以使用Go语言编译器做类型检查,并用类型断言表达式做辅助。 明显的缺陷就是无法灵活地改变字典的键和值的类型,需求多样化则编码工作量增加。 方案二: 无需在程序运行之前明确键和值的类型,只要在初始化sync.Map的时候,动态地给它们就可以,主要使用reflect包中的函数和数据类型,外加一些简单的判等操作。 更灵活,但是反射操作降低程序的性能。 0.5.3. sync.Map如何尽量避免使用锁 sync.Map类型在内部使用大量的原子操作来存取键和值,并使用两个原生的map作为存储介质: 一个原生map被存在了sync.Map的read字段中,该字段是sycn/atomic.Value类型的。这个原生字典可以被看做一个快照,它总会在条件满足时,去重新保存所属的sync.Map值中包含的所有键值对。 read字段虽然不会增减其中的键,但却允许变更其中的键所对应的值,所以它不是传统意义上的快照,它的只读特性只是对其中键的集合而言的。 read字段的类型可知,sync.Map在替换read的时候根本用不着锁,并且read字段在存储键值对的时候,还在值之上封装了一层: 先把值转换为unsafe.Pointer`类型的值 再把后者封装后存储在其中的原生map中 这样,在变更某个键所对应的值的时候,就可以使用原子操作了。 另一个原生map存在sync.Map的dirty字段中,它存储键值对的方式与read字段一只,它的键类型是interface{},并且同样把值先做转换和封装,然后再进行存储。 read和dirty字段如果都存有同一个键值对,那么这里的两个键指的肯定是同一个基本值,对于两个值来说也是如此。 这两个字典在存储键和值的时候,只会存入它们的某个指针,而不是基本值。 读取: sync.Map在查找指定的键锁对应的值的时候,总会先去read中寻找,并不需要锁定互斥锁 只有在确定read中没有,但dirty中可能还有这个键的时候,它才会在锁的包含下去访问dirty 存储: sync.Map在存储键值对的时候,只要read中已存有这个键 并且该键值对未被标记为“已删除”,就会把新值存到里面直接返回,这种情况下也不需要用到锁 否则,它才会在锁的保护下把键值对存储到dirty中,这个时候,该键值对的“已删除”标记会被抹去 只有当一个键值对应该被删除,但却仍然存在与read中的时候,才会被用标记为“已删除”的方式进行逻辑删除,而不会直接被物理删除。这种情况会在重建dirty后的一段时间内出现,过不了多久,就会被真正删除。在查找和遍历键值对的时候,已经被逻辑删除的键值对永远会被无视。 对于删除键值对,sync.Map会先去检查read中是否有对应的键,如果没有,dirty中可能有,那么它会在锁保护下,试图从dirty中删除该键值对。最后,sync.Map会把该键值对中指向值的那个指针置为nil,这是另一种逻辑删除方式。 需要注意,read和dirty之间是会相互转换的,在dirty中查找键值对次数足够多的时候,sync.Map会把dirty直接作为read,保存在它的read字段中,然后把代表dirty的dirty字段置为nil。 在这之后,一旦再有新的键值对存入,它就会依据read去重建dirty,这个时候会把read中已经逻辑删除的键值对过滤掉,这些操作都在锁的保护下进行。 综上,sync.Map的read和dirty中的键值对集合并不是实时同步的,它们在某些时间段内可能会不同,由于read中的键值对的集合不能被改变,所以其中的键值对有时候可能是不全的,相反,dirty中的键值对集合总是完全的,并且其中不会包含已被逻辑删除的键值对。 因此在读操作很多,写操作很少的情况下,sync.Map的性能会更好,在几个写操作当中,新增键值对的操作对sync.Map的性能影响最大,其次是删除操作,最后是修改操作。如果被操作的键值对已经存在于sync.Map的read中,并且没有被逻辑删除,那么修改它并不会使用到锁,对其性能的影响会很小。

18-atomic 阅读更多

0.1. Go对原子操作的支持 0.1.1. 注意点1 0.1.2. 注意点2 0.1.3. 注意点3 0.1.4. 注意点4 0.1.5. sync/atomic.Value 0.1.5.1. 注意点 0.1.5.2. 建议 0.2. 原子操作与互斥所的区别 并发编程里经常用到的技术,除了Context、计时器、互斥锁、通道外还有一种技术--原子操作在一些同步算法中会被用到。 对于一个Go程序来说,Go语言运行时系统中的调度器会恰当地安排其中所以的goroutine运行。 不过,在同一时刻,只可能有少数的goroutine真正地处于运行状态,并且这个数量只会与M的数量一致,而不会随着G的增多而增长。 为了公平期间,调度器总是会频繁地换上或者换下这些goroutine。 换上:让一个goroutine由非运行状态转为运行状态,并促使其中的代码在某一个CPU核心上执行 换下:是一个goroutine中的代码中断执行,并让它由运行状态转为非运行状态 这个中断的时机有很多,任何两条语句执行的间隙,甚至在某条语句执行的过程中都可以,即使这条语句在临界区之内。 互斥锁虽然可以保证临界区中代码的串行执行,但却不能保证这些代码执行的原子性(atomicity)。 在众多同步工具中,真正能够保证原子性的只有原子操作。 原子操作在进行的过程中不允许中断。在底层,这会由CPU提供芯片级别的支持(一个原子操作只会由一个独立的CPU指令代表和完成),所以绝对有效。 即使在拥有多CPU核心或者多个CPU的计算机系统中,原子操作的保证也是不可撼动的。 原子操作可以完全地消除竞态条件,并能够绝对地保证并发安全性。 原子操作是无锁的,直接通过CPU指令直接实现,在执行速度上比其他的同步工具快很多,通常会高出好几个数量级。 事实上,其它同步技术的实现常常依赖于原子操作。 正是因为原子操作不能被中断,所以需要足够简单,并且要求快速。 如果原子操作迟迟不能完成,而它又不会被中断,那么将会给计算机执行指令的效率带来多么大的影响。因此操作系统层面只针对二进制或整数的原子操作提供支持。 0.1. Go对原子操作的支持 Go语言的原子操作也是基于操作系统和CPU的,所以只能对少数数据类型的值提供原子操作函数。这些函数在sync/atomic包中,用于同步访问整数和指针。 Go语言提供的原子操作都是非入侵式的,原子操作可以确保gorotuine之间不存在数据竞争。 竞争条件是由于异步的访问共享资源,并试图同时读写该资源而导致的,使用互斥锁和通道的思路都是在线程获得到访问权后阻塞其他线程对共享内存的访问,而使用原子操作解决数据竞争问题则是利用了其不可被打断的特性。 支持的5种原子操作: 加法(add) 比较并交换(compare and swap,简称CAS) 加载(load) 存储(store) 交换(swap) 这些函数针对的数据类型并不多,但是对这些类型中的每一个都有一套函数给予支持。 原子操作支持的6种数据类型: int32 int64 uint32 uint64 uintptr unsafe包中的Pointer:对unsate.Pointer类型没有提供原子加法操作的函数 sync/atomic包中还提供了名为Value的类型,用于存储任意类型的值。 0.1.1. 注意点1 传入原子操作函数的第一个参数值对应的都应该是那个被操作的值,如: func AddInt32(addr *int32, delta int32) (new int32) // 以原子方式将增量添加到 *addr 并返回新的值 上面函数的一个参数应该是那个需要被增大的整数。这个参数类型为什么是*int32?因为原子操作函数需要的是被操作值的指针,而不是这个值本身,被传入函数的参数值都会被复制,像这种基本类型的值一旦被传入函数,就已经与函数外的那个值毫无关系了。传入值本身没有任何意义。 unsafe.Pointer类型虽然是指针类型,但是那些原子操作函数要操作的是这个指针值,而不是它指向的那个值,所以需要的仍然是指向这个指针值的指针。 只要原子操作函数拿到了被操作值的指针,就可以定位到存储该值的内存地址,就能通过底层的指令,准确地操作这个内存地址上的数据。 0.1.2. 注意点2 用于原子加法操作的函数可以做原子减法吗? func AddInt32(addr *int32, delta int32) (new int32) // 以原子方式将增量添加到 *addr 并返回新的值 上面函数的第二个参数代表增量,它的类型是int32,是有符号的,将增量值设置为负数就能实现原子减法操作。 但是下面两个函数就不能直接将增量值赋予为负数进行减法,因为第二个表示增量的参数是无符号的: func AddUint32(addr *uint32, delta uint32) (new uint32) func AddUint32(addr *uint32, delta uint32) (new uint32) 假设要将上面的增量赋予-3,可以进行类型转换:uint32(int32(-3)),这样转换后Go编译器会报错“常量-3不在unit32类型可表示范围内”,即表达式的结果值溢出。 换个操作方式,先将int32(-3)赋予增量,在将增量的类型转换为unit32,这样可以绕过编译器的检查并得到正确的结果。将这个结果作为第二个参数值传递给原子加法操作来实现原子减法目的。 官方文旦展示了另一种操作方式,^uint32(-N-1),其中N表示增量的负整数,即: 先把增量的绝对值减去1 再把得到的无类型的整数常量转换我uint32类型的值 在这个值上做按位异或操作得到最终的参数值 0.1.3. 注意点3 CAS与swap的不同及优势: CAS是有条件的交换操作,只有在条件满足的情况下才会进行值的交换。(所谓的交换就是把新的值赋予给变量,并返回旧的值) 在进行CAS操作的时候,函数会先判断被操作的变量的当前值是否与预期的旧值相等。 如果相等,就把新值赋给该变量,并返回true以表明交换操作已进行 如果不相等,就忽略交换操作,并返回false CAS操作并不是单一的操作,而是一种操作组合。这与其他的原子操作都不同,因此它的用途更广泛。例如,将它与for循环联用实现一种建议的自旋锁(spinlock): for { if atomic.CompareAndSwapInt32(&num2, 10, 0) { fmt.Println("The second number has gone to zero.") break } time.Sleep(time.Millisecond * 500) } // 在for循环中的CAS操作可以不停检查某个需要满足的条件,一旦条件满足就退出for循环 // 只要条件未满足,当前的流程就会被一直阻塞在这里 这与互斥锁类似,但适用场景不同: 互斥锁 :总是假设共享资源的状态会被其他的goroutine频繁地改变 for循环+CAS操作:总是假设共享资源状态的改变不是很频繁,或者,它的状态总会变成期望的那样,这是一种乐观假设 0.1.4. 注意点4 对一个变量的写操作都是原子操作(如,加减、存储、交换等),那么对它的读操作还需要是原子操作吗? 有必要。就像读写锁中写操作与读操作是互斥的。这是为了防止读操作读到未被修改完的值。 所以要对共享资源进行保护,那就要完全的保护,不完全的保护基本上和不保护没有什么区别。 原子操作支持的数据类型很有限,所以在很多场景下,互斥锁更加适用。 一旦确定某个场景下可以适用原子操作函数,比如只涉及并发地读写单一的整数类型值,或者多个互不相关的整数类型值,就不要考虑互斥锁,因为原子操作函数的执行速度要比互斥锁快很多。使用起来也更简单,不会涉及临界区的选择,死锁等问题。使用CAS操作要注意可能引起“阻塞”流程。 0.1.5. sync/atomic.Value 为了扩大原子操作的适用范围,Go 1.4在sync/atomic包中添加了一个新的类型Value,此类型的值相当于一个容器,可以被用来原子地存储和加载任意的值。 atomic.Value类型是开箱即用的,声明一个该类型的变量(简称原子变量)之后就直接使用,该类型只有两个指针方法:Store和Load。 0.1.5.1. 注意点 atomic.Value类型的值(原子值)被正真使用(用原子变量存储了值,就相当于真正使用),就不应该再被复制了。 atomic.Value类型属于结构体类型,而结构体类型属于值类型。所以复制这个值会产生一个完全分离的新值,两者怎么改变都不会相互影响。 用原子值来存储值,有两个强制性的使用规则: 不能用原子值存储nil:即不能把nil作为参数值传入原子值的Store方法,否则会引发一个panic 注意,如果有一个接口类型的变量,它的动态值的nil,但动态类型却不是nil,那么它的值就不等于nil,所以这样一个变量的值可以被存入原子值中。 向原子值存储的第一个值,决定了它今后能且只能存储哪一个类型的值 例如,对于一个第一次存储了string类型值的原子值,在调用Store发放存储其他类型时会引发一个panic,提示这次存储的值的类型与之前的不一致。 在原子值内部依据被存储值的实际类型进行判断,所以即使实现了同一个接口的不同类型,它们的值也不能被先后存储在同一个原子值中。 遗憾的是: 无法通过某个方法知道一个原子值是否已经被真正使用 无法通过常规图解得到一个原子值存储的实际类型 这使得误用原子值的可能性大大增加,尤其在多个地方使用同一个原子值。 0.1.5.2. 建议 不要把内部使用的原子值暴露给外界,比如声明一个全局的原子变量并不是一个正确的做法,这个变量的访问权限至少应该是包级私有。 如果不得不让包外,或者模块外的代码使用原子值,可以声明一个包级私有的原子变量,然后再通过一个或多个公开的函数,让外界间接地使用到它。注意,这种情况下,不要把原子值传递到外界,不论是传递原子值本身还是它的指针值。 如果通过某个函数可以向内部的原子值存储值的话,那么就应该在这个函数中先判断被存储值类型的合法型,如果不合法,直接返回对应的错误理性,从而避免panic的发生。 如果可能的话,把原子值封装到一个数据类型中,比如结构体,这样既可以通过该类型的方法更加安全地存储值,有可以在该类型中包含可存储值的合法信息。 尽量不要在原子值中存储引用类型的值,因为容易造成安全漏洞。如下代码所示: var box6 atomic.Value v6 := []int{1, 2, 3} // 切片,引用类型 box6.Store(v6) v6[1] = 4 // 注意,此处的操作不是并发安全的! // 上述操作修改了切片中的值,也就修改了box6中存储的值 // 这样绕过了原子值而进行了非并发安全的操作 // 修改为这样 store := func(v []int) { replica := make([]int, len(v)) // 为切片值创建一个副本,副本涉及的数据与原值毫不相关 copy(replica, v) box6.Store(replica) // 把副本存储到box6中 } store(v6) v6[2] = 5 // 此处的操作是安全的。 // 修改切片中的值,不会修改box6中的值 0.2. 原子操作与互斥所的区别 互斥锁是一种数据结构,用来让一个线程执行程序的关键部分,完成互斥的多个操作。 原子操作是针对某个值的单个互斥操作,这意味着没有其他线程可以打断它。 原子操作的优势,更轻量:比如CAS可以在不形成临界区和创建互斥量的情况下完成并发安全的值替换操作。这可以大大的减少同步对程序性能的损耗。 原子操作的劣势:比如CAS操作的做法趋于乐观,总是假设被操作值未曾被改变(即与旧值相等),并一旦确认这个假设的真实性就立即进行值替换,那么在被操作值被频繁变更的情况下,CAS操作并不那么容易成功。 把互斥锁理解为悲观锁,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程。 atomic包提供了底层的原子性内存原语,这对于同步算法的实现很有用。这些函数一定要非常小心地使用,使用不当反而会增加系统资源的开销。 对于应用层来说,最好使用channel或sync包中提供的功能来完成同步操作。

19-context 阅读更多

0.1. 使用Context包实现一对多goroutine协作流程 0.1.1. 撤销信号 0.1.2. 撤销信号在上下文树中的传播 0.1.3. 通过Context携带数据,并获取数据 0.2. 总结 使用WaitGroup可以实现一对多的goroutine协作流程同步,如果一开始不能确定子任务的goroutine数量,那么使用WaitGroup值来协调它们和分发子任务的goroutine就存在一定的风险。 一个解决方案是:分批地启用执行子任务的goroutine。 0.1. 使用Context包实现一对多goroutine协作流程 func coordinateWithContext() { total := 12 var num int32 fmt.Printf("The number: %d [with context.Context]\n", num) // 调用context.Background和context.WithCancel创建一个可撤销的context对象ctx和一个撤销函数cancelFunc ctx, cancelFunc := context.WithCancel(context.Background()) for i := 1; i <= total; i++ { // 每次迭代创建一个新的goroutine go addNum(&num, i, func() { // 在子goroutine中原子性的Load num变量 if atomic.LoadInt32(&num) == int32(total) { // 如果num与total相等,表示所有子goroutine执行完成 // 调用context的撤销函数 cancelFunc() } }) } // 调用Done函数,并试图针对该函数返回的通道进行接收操作 // 一旦cancelFunc被调用,针对该通道的接收操作就会马上结束 <-cxt.Done() fmt.Println("End.") } context.Context类型在1.7版本引入后,许多标准库都进行了扩展支持,包括:os/exec,net,database/sql,runtime/pprof,runtime/trace。 context类型是一种非常通用的同步工具,它的值不但可以被任意地扩散,而且还可以被用来传递额外的信息和信号。更具体的说,Context类型可以提供一类代表上下文的值,此类值是并发安全的,可以被传播到多个goroutine。 Context类型是一个接口类型,context包中实现该接口的所有的私有类型,都是基于某个数据类型的指针类型,所以如此传播并不会影响该类型值的功能和安全。 Context类型的值是可以衍生,可以通过Context值产生出任意个子值,这些子值可以携带其父值的属性和数据,也可以响应通过其父值传达的信号。 Context值共同构成了一棵代表了上下文全貌的树形结构。这棵树的树根(上下文根节点)是一个已经在context包中预定义好的Context值,它是全局唯一的。通过调用context.Background函数可以获取它。此处的上下文根节点只是最基本的支点,不通过任何额外的功能,既不能被撤销也不能携带任何数据。 context包中包含四个用于衍生context值的函数: WithCancel:产生一个可撤销的parent的子值 WithDeadline,WithTimeout:产生一个会定时撤销的parent的子值 WithValue:产生一个会携带额外数据的parent的子值 这些函数的第一个参数类型都是context.Context,名称都是parent,这个位置上的参数都是它们将产生的Context值的父值。 0.1.1. 撤销信号 Context接口类型中有两个方法与撤销相关: Done方法返回一个元素类型为struct{}的接收通道,这个通道的用途不是传递元素值,而是让调用方去感知撤销当前Context值的那个信号。一旦当前Context值被撤销,接收通道会立即关闭,(对于一个未包含任何元素值的通道,它的关闭使任何针对它的接收操作立即结束)。 Err方法,让Context值的使用方感知到撤销信号的同时得到撤销的具体原因,该方法的结果是error类型的,并且其值只可能等于: context.Canceled变量的值:表示手动撤销 或者context.DeadlineExceeded变量的值:表示给定的过期时间已到而导致撤销 context.WithCancel函数产生一个可撤销的Context值,还会获得一个用于出发撤销信号的函数,通过调用该函数,可以触发针对这个Context值的撤销信号,一旦触发,撤销信号会立即被传达给这个Context值,并由它的Done方法的结果值(一个接收通道)表达出来。 撤销函数只负责触发信号,对应的可撤销的Context值也只负责传达信号,它们不会管后边具体的撤销操作,代码在感知到撤销信号后,可以进行任意的操作,Context值对此并没有任何的约束。 更进一步,这里的撤销最原始的含义是: 终止程序对某种请求(比如HTTP请求)的响应, 取消对某个指令(如SQL指令)的处理, 这是创建Context包和Context类型时的初衷。 0.1.2. 撤销信号在上下文树中的传播 Context包中包含四个用于衍生Context值的函数,其中的WithCancel,WithDeadline,WithTimeout都是被用来基于给定的Context值产生可撤销的子值。 func WithCancel(parent Context) (ctx Context, cancel CancelFunc) // 返回一个可被撤销的context值和一个出触发撤销信号的函数 撤销函数被调用后,Context值会先关闭它内部的接收通道,即Done方法会返回的那个通道。然后,它会向它的所有子值(或者说子节点)传达撤销信号。这些子值会继续把撤销信号传播下去,最后这个context值会断开它与其父值之间的关联。 func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) WithDeadline和WithTimeout函数生成的Context值也是可撤销的,它们不但可以被手动撤销,还会依据在生成时被给定的过期时间,自动地进行定时撤销(定时撤销的功能借助内部的计时器来实现),撤销的同时释放内部的计时器。 注意,通过调用context.WithValue函数得到的Context值是不可撤销的。撤销信号在被传播时,如遇到它们则会直接跨过,并试图将信号直接传给它们的子值。 0.1.3. 通过Context携带数据,并获取数据 func WithValue(parent Context, key, val interface{}) Context WithValue函数在产生新的包含数据的Context值的时候,需要三个参数,即:父值、键(与map对键的约束类似,类型必须可判等)、值。因为从中获取数据的时候,根据给定的键来查找对应的值。不过这里并没有用map来存储数据。 Context类型的Value方法就是被用来获取数据的,在调用含数据的Context值的Value方法时,它会先判断给定的键,是否与当前值中存储的键相等,如果相等就把该值中存储的值直接返回,否则就到其父值中继续查找。如果父值中仍未存储相等的键,那么继续向上直到查找到根节点。 除了包含数据的Context可以存储数据,其他的Context值都不能携带数据,Context的Value方法在向上查找的过程中会直接跳过这几种类型的Context值。 如果调用的Value方法所属的Context本身就不包含数据,那么实际调用的就会是其父值的Value方法。因为这几种Context值的实际类型是结构体,它们通过将父值嵌入到自身来表达父子关系。 Context接口并没有提供改变数据的方法,因此在通常情况下,只能通过上下文树中添加含数据的Context值来存储新的数据,或者通过撤销此种值的父值丢弃相应的数据。如果存储在这里的数据可以从外部改变,那么必须自行保证安全。 0.2. 总结 Context类型的实际值分为三种: 根Context值 可撤销的Context值 手动撤销,手动调用撤销函数 定时撤销,设置定时撤销的时间,且不可更改,可在过期时间到达之前手动进行撤销 含数据的Context值,可以携带数据,每个值可以存储一对键值对,调用Value方法,它会沿着树根的方向逐个值进行查找,如果发现相等立即返回,否则将在最后返回nil 所有的Context值共同构成一颗上下文树,这棵树的作用域是全局的,根Context值是全局唯一的,不提供任何额外的功能。 撤销操作是Context值能够协调多个goroutine的关键,撤销信号总是会沿着上下文树叶子节点的方向传播。 含数据的Context不能被撤销,能被撤销的Context无法携带数据,它们共同组成一个整体(上下文树)。

20-Unicode 阅读更多

Golang字符编码基础 Golang的标识符可以包含“任何Unicode编码可以表示的字母字符”。 虽然可以直接把一个整数值转换为一个string类型的值,但是被转换的整数值应该可以代表一个有效的Unicode代码点,否则转换的结果将会是�,即,一个仅由高亮的问好组成的字符串值。 当一个string类型的值被转换为[]rune类型值的时候,其中的字符串会被拆分为一个个Unicode字符。 Golang的代码正式由Unicode字符组成的,所以的源代码都必须按照Unicode编码规范中的UTF-8编码格式进行编码。也就是说,Golang的源码文件必须使用UTF-8编码格式进行存储,如果源码文件中出现了非UTF-8编码的字符,那么在构建、安装和运行的时候,go命令会报错“illegal UTF-8 encoding”。 ASCII编码 ASCII由ANSI制定的单字节字符编码方案,可以用于基于文本的数据交换。ASCII编码方案使用单个字节(byte)的二进制数来编码一个字符。 标准的ASCII编码用一个字节的最高比特(bit)位作为奇偶校验位, 而扩展的ASCII编码则将刺猬用于表示字符。 ASCII编码支持的可打印字符和控制字符的集合叫做ASCII编码集。 Unicode编码规范是另一种更通用、针对数码字符和文本的字符编码标准,它为世界上现存的所有自然语言中的每个字符,都设定了一个唯一的二进制编码。它定义了不同的自然语言的文本数据在国际间交换的统一方式,并为全球化软件创建了一个重要的基础。 Unicode编码规范以ASCII编码集为出发点,并突破了ASCII只能对拉丁字母进行编码的限制。它不但提供了可以对世界上超过百万的字符进行编码的能力,还支持所有已知的转义序列和控制代码。 在计算机内部,抽象的字符会被编码为整数,这些整数的范围被成为代码空间,在代码空间之内,每一个特定的整数都被成为一个代码点。一个受支持的抽象字符会被映射并分配给某个特定的代码点,反过来,一个代码点总是可以被看成一个被编码的字符。 Unicode编码 Unicode编码规范通常使用十六进制标表示法来表示Unicode代码点的整数值,并使用“U+”作为前缀。 如,英文字母字符“a”的Unicode代码点是“U+0061”。 在Unicode编码规范中,一个字符能且只能有与它对应的那个代码点表示。Unicode编码规范提供了三种不同的编码格式: UTF-8 UTF-16 UTF-32 在这三种编码格式的名称中,“-”右边的整数的含义是,以多少个比特位作为一个编码单元。 例如,UTF-8就是以8个比特位,也就是一个字节作为一个编码单元,并且,它与标砖的ASCII编码是完全兼容的,也就是说在[0x00,0x7F]的范围内,这两种编码表示的字符都是相同的,这是UTF-8的巨大优势。 UTF是UCS Transformation Format是缩写,UCS是Universal Charater Set或者Unicode Character Set的缩写,所以UTF可以翻译为UNicode转换格式,它代表的是字符与字节序列之间的转换方式。 UTF-8是一种可变宽的编码方案,它会用一个或者多个字节的二进制数字来表示某个字符,最多使用四个字节。比如: 对于一个英文字符,它仅需要一个字节的二进制数就可以表示 对于一个中文字符,它需要要三个字节的二进制数才能够表示 不论怎么,一个受支持的字符总是可以由UTF-8编码为一个字节序列,简称UTF-8编码值。 string类型的值在底层的表达 在底层,string类型的值是由一系列相对应的Unicode代码点的UTF-8编码值来表达的。在Golang中一个string类型的值: 既可以被拆分为一个包含多个字符的序列:以rune为元素的切片可以表示 也可以被拆分为一个包含多个字节的序列:以byte为元素的切片可以表示 rune是Golang特有的一个基本数据类型,它的一个值表示一个字符,即:一个Unicode字符。比如'G'、'o'、'爱'、'好'、'者'代表的就是一个Unicode字符。 因为UTF-8编码格式会把一个Unicode字符编码为一个长度为1~4个字节序列,所以一个rune类型的值可以由一个或多个字节来表示。 type rune = int32 // rune类型实际上是int32类型的一个别名类型 // 一个rune类型的值会由四个字节宽度的空间来存储 // 它的存储空间总是能够存下一个UTF-8编码值 一个rune类型的值,在底层就是一个UTF-8编码值。前者是便于人类理解的外部展现,后者是便于计算机系统理解的内在表达。 str := "Go爱好者 " fmt.Printf("The string: %q\n", str) // 字符串值“Go 爱好者”被转换为[]rune类型的值, // 其中每一个字符(不论中英文)都会独立成为一个rune类型的元素值 fmt.Printf(" => runes(char): %q\n", []rune(str)) // => runes(char): ['G' 'o' '爱' '好' '者'] // 每个rune类型的值在底层都是由一个UTF-8编码值来表达的, // 所以可以使用下面这种方式展示字符序列 fmt.Printf(" => runes(hex): %x\n", []rune(str)) // => runes(hex): [47 6f 7231 597d 8005] // 将每个字符的UTF-8编码值都拆成响应的字节序列 // 每三个字节对应一个中文字符 fmt.Printf(" => bytes(hex): [% x]\n", []byte(str)) // => bytes(hex): [47 6f e7 88 b1 e5 a5 bd e8 80 85] 对于一个多字节的UTF-8编码值来说,可以把它当成一个整体转换为单一的整数,也可以先把它拆成字节序列,在把每个字节分别转换为一个整数,从而得到多个整数。这两种表示法展现出来的内容往往会不一样,比如: 对于中文字符‘爱’它的UTF-8编码值 可以展现为单一的整数:7231 也可以展现为三个整数:e7 88 b1 一个string类型的值会由若干个Unicode字符组成,每个Unicode字符都可以由一个rune类型的值来承载。这些字符在底层都会被转换为UTF-8编码值,而这些UTF-8编码值有会以字节序列的形式表达和存储。因此一个string类型的值在底层就是一个能够表达若干个UTF-8编码值的字节序列。 range子句遍历字符串 带有range子句的for语句会先把遍历的字符串值拆成一个字节序列,然后再试图找出这个字节序列中包含的一个UTF-8编码值,或者说每一个Unicode字符。 这样的for语句可以为两个迭代变量赋值: 第一个变量的值:就是当前字节序列中的某个UTF-8编码值的第一个字节所对应的那个索引值 第二个变量的值:这个UTF-8编码值代表的那个Unicode字符,其类型是rune str := "Go 爱好者 " for i, c := range str { fmt.Printf("%d: %q [% x]\n", i, c, []byte(string(c))) } // output // 输出for-range语句的两个迭代变量和第二个值的字节序列形式 0: 'G' [47] 1: 'o' [6f] 2: '爱' [e7 88 b1] 5: '好' [e5 a5 bd] 8: '者' [e8 80 85] // for-range语句可以逐一地迭代出字符串值里的每个Unicode字符, // 但是相邻的Unicode字符的索引值并不一定是连续的, // 这取决与前一个Unicode字符是否为单字节字符。

21-strings 阅读更多

Golang不但拥有可以独立代表Unicode字符的类型rune,而且还可以对字符串值进行Unicode字符拆分的for语句。标准库Unicode包及其子包还提供了很多函数和数据类型,可以解析各种内容中的Unicode字符。 标准库中的strings代码包,用到了unicode包和unicode/utf8包中的程序,如: strings.Builder类型的WriteRune方法 strings.Reader类型的ReadRune方法 与string值相比strings.Builder类型的优势 已存在的内容不可变,但可以拼接更多的内容 减少内存分配和内容拷贝的次数 可将内容充值,可重用值 string类型 在Golang中,string类型的值是不可变的,如果想要获得一个不一样的字符串,只能基于原来的字符串进行裁剪、拼接等操作,从而生成一个新的字符串。 裁剪:可以使用切片表达式 拼接:可以使用操作符“+”实现 在底层,一个string值的内容会被存储到一块连续的内存空间中,同时,这块内存容纳的字节数量会被记录下来,并用于表示该string值的长度。可以把这块内存的内容看成一个字节数组,而相应的string值则包含了指向字节数组头部的指针值。这样在string值上应用切片表达式,就相当于在对其底层的字节数组做切片。 在进行字符串拼接的时候,Golang会把所有被拼接的字符串依次拷贝到一个崭新且足够大的连续内存空间中,并把持有相应指针的string值作为结果返回。当程序中存在过多的字符串拼接操作的时候,会对内存分配产生非常大的压力。 虽然string值在内部持有一个指针,但其类型仍然属于值类型,由于string值的不可变,其中的指针值也为内存空间的节省做出了贡献。 一个string值会在底层与它所有的副本共用同一个字节数组,由于这里的字节数组永远不会被改变,所有这样是绝对安全的。 strings.Builder类型 与string相比strings.Builder值的优势体现在字符串拼接方面。Builder值中有一个用于承载内容的容器(简称内容容器),它是一个以byte为元素类型的切片(字节切片),由于这样的字节切片的底层数组就是一个字节数组,它与string值存储内容的方式是一样的。 实际上它们都是通过unsafe.Pointer类型的字段来持有那个指向了底层字节数组的指针值。因为这样的构造Builder值拥有高校利用内存的前提条件。 对于字节切片本身来说,它包含的任何元素值都可以被修改,但是Builder值并不允许这样做,其中的内容只能够被拼接或者被重置。这就意味着Builder值中的内容是不可变的。因此,利用Builder值提供的方法(Write、WriteByte、WriteRune、WriteString)拼接更多的内容,而丝毫不用担心这些方法会影响到已存在的内容。 通过调用上述方法把新的内容拼接到已存在的内容的尾部,如果有必要,Builder会自动地对自身的内容容器进行扩容,这里的自动扩容策略与切片的扩容策略一致。内容容器的容量足够时不会进行扩容,没有扩容,那么已存在的内容就不会被拷贝。 通过Builder的Grow方法可以进行手动扩容,它接收一个int类型的参数n,该参数用于代表将要扩充的字节数量。Grow方法会把其所属的内容容器的容量增加n个字节,它会生成一个字节切片作为新的内容容器,该切片的容量会是原容器容量的两被在加上n,之后会把原容器中的所有字节全部拷贝到新容器中。 var builder1 strings.Builder // 省略若干代码。 fmt.Println("Grow the builder ...") builder1.Grow(10) fmt.Printf("The length of contents in the builder is %d.\n", builder1.Len()) // 如果当前内容容器的未用容量已经够用,即未用容量 >=n ,那么Grow方法什么也不做 fmt.Println("Reset the builder ...") // 调用Reset方法,可以让Builder值重新回到零值状态,就像它从未被使用过那样 // 一旦被重置,Builder值中原有的内容容器会被直接丢弃 // 与其他的所有内容,将会被Go语言的垃圾回器标记并回收掉 builder1.Reset() fmt.Printf("The third output(%d):\n%q\n", builder1.Len(), builder1.String()) strings.Builder类型的使用约束 在已被真正使用后就不可在被复制 调用Builder值的拼接方法和扩容方法就意味着开始真正使用它了,因为这些方法都会改变其所属值中的内容容器的状态,一旦调用了它们,就不能再以任何的方式对其所属值进行复制了,否则只要在任何副本上调用上述方法都会引发panic。 这种panic会告诉我们,这样的使用方式并不合法,因为这里的Builder值是副本而不是原值,这的复制方法包括但不限于函数间传递值、通过通道传递值、把值赋予变量等 var builder1 strings.Builder builder1.Grow(1) builder3 := builder1 //builder3.Grow(1) // 这里会引发 panic。 _ = builder3 由于Builder值不能在被复制,所以肯定不会出现多个Builder值中的内容容器公用一个底层字节数组的情况,这样避免了多个同源的Builder值在拼接内容时可能产生的冲突问题。 虽然已经使用的Builder值不能再被复制,但是它的指针值却可以,无论什么时候,都可以通过任何方式复制这样的指针值。这样的指针值指向的都会是同一个Builder值。 f2 := func(bp *strings.Builder) { (*bp).Grow(1) // 这里虽然不会引发 panic,但不是并发安全的。 builder4 := *bp //builder4.Grow(1) // 这里会引发 panic。 _ = builder4 } f2(&builder1) 这就产生了一个问题,如果Builder值被多方同时操作,那么其中的内容就可能产生混乱,这就是所说的操作冲突和并发安全问题。 由于其内容不是完全不可变,所以需要使用方自行解决操作冲突和并发安全问题 Builder值自己是无法解决这些问题的,所以在通过传递其指针值共享Builder值的时候,一定要确保各方对它的使用是正确的、有序的,并且是并发安全的,彻底的解决方案是,绝对不要共享Builder值以及它的指针值。 可以在各处分别声明一个Builder值来使用,也可以先声明一个Builder值,然后在真正使用它之前,便将它的副本传到各处。 先使用在传递,只要在传递之前调用它的Reset方法即可。 builder1.Reset() builder5 := builder1 builder5.Grow(1) // 这里不会引发 panic。 关于复制Builder值的约束是有意义的,也是很有必要的。虽然仍然在通过某些方式共享Builder值,但最好还是不要以身犯险,各自为政是最好的解决方案。对于处在零值状态的Builder值,复制不会有任何问题。 strings.Reader类型的值如何高效读取字符串 与strings.Builder类型恰恰相反,strings.Reader类型是为了高效读取字符串而存在的。它的高效体现在它对字符串的读取机制上,它封装了很多用于在string值上读取内容的最佳实践。 strings.Reader类型的值(简称Reader值),可以让我们很方便地读取一个字符串中的内容,在读取的过程中,Reader值会保存已读取的字节的计数(简称已读计数)。 已读计数,代表这下一个读取的起始索引为止,Reader值就是依靠这样的一个计数,以及针对字符串的切片表达式,从而实现快读速读取的。 已读计数,也是读取回退和位置设定时的重要依据。虽然它属于Reader的内部结构,但我们还是可以通过该值的Len方法和Size方法把它计算出来的。如下代码所示: var reader1 strings.Reader // 省略若干代码。 readingIndex := reader1.Size() - int64(reader1.Len()) // 计算出的已读计数。 Reader值拥有的大部分用于读取的方法都会及时更新已读计数,比如: ReadByte方法会在读取成功后将这个计数的值加1 ReadRune方法读取成功之后,会把被读取的字符所占用的字节数作为计数的增量 Seek方法也会更新该值的已读计数器,它的主要擢用正是设定下一次读取的起始索引位置。如果把io.SeekCurrent的值作为第二个参数传给该方法,那么它会依据当前的已读计数,以及第一个参数offset的值来计算新的计数值。Seek方法会返回新的计数值,所以我们可以很容易地验证这一点,如下所示。 offset2 := int64(17) expectedIndex := reader1.Size() - int64(reader1.Len()) + offset2 fmt.Printf("Seek with offset %d and whence %d ...\n", offset2, io.SeekCurrent) readingIndex, _ := reader1.Seek(offset2, io.SeekCurrent) fmt.Printf("The reading index in reader: %d (returned by Seek)\n", readingIndex) fmt.Printf("The reading index in reader: %d (computed by me)\n", expectedIndex) ReadAt方法是一个例外,它既不会依据已读计数进行读取,也不会在读取之后更新它。因此这个方法可以自由地读取其所属的Reader值中的任何内容。 综上所属,Reader值实现高效读取的关键在于它内部的已读计数,计数的值就代表这下一次读取的起始索引位置,它可以很容地被计算出来,Reader值的Seek方法可以直接设定该值中的已读计数值。

22-bytes 阅读更多

strings包和bytes包在API方面非常相似,单从它们提供的函数的数量和功能上说,差别微乎其微。 strings包主要面向的是Unicode字符和经过UTF-8编码的字符串 bytes包主要面向的是字节和字节切片 bytes.Buffer // Buffer是一个具有读写方法的可变大小的字节缓冲区。 // 零值的Buffer是一个准备使用的空缓冲区。 type Buffer struct { buf []byte // contents are the bytes buf[off : len(buf)] off int // read at &buf[off], write at &buf[len(buf)] lastRead readOp // last read operation, so that Unread* can work correctly. } 主要用途,作为字节序列的缓冲区。与strings.Builder一样,bytes.Buffer也是开箱即用。 strings.Builder:只能拼接和导出字符串 bytes.Buffer:不但可以拼接、截断其中的字节序列,以各种形式导出其中的内容,还可以顺序读取其中的子序列 在内部,bytes.Buffer类型同样使用字节切片作为内容容器,并且与strings.Reader类型类似,bytes.Buffer有一个int类型的字段,用于表示已读字节的计数。已读字节的计数无法通过bytes.Buffer提供的方法计算出来。 var buffer1 bytes.Buffer contents := "Simple byte buffer for marshaling data." fmt.Printf("Writing contents %q ...\n", contents) buffer1.WriteString(contents) fmt.Printf("The length of buffer: %d\n", buffer1.Len()) // 返回其中未被读取部分的长度,而不是已存内容的总长度 fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap()) // Output Writing contents "Simple byte buffer for marshaling data." The length of buffer: 39 // 空格,标点,字符加起来,未调用任何读取方法,因此已读计数为零 The capacity of buffer: 64 // 根据切片自动扩容策略,64看起来也很合理p1 := make([]byte, 7) n, _ := buffer1.Read(p1) // 从buffer中读取内容,并用它们填满长度为7的字节切片 fmt.Printf("%d bytes were read. (call Read)\n", n) fmt.Printf("The length of buffer: %d\n", buffer1.Len()) fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap()) // Output 7 bytes were read. (call Read) The length of buffer: 32 The capacity of buffer: 64 Buffer 值的长度:Buffer值的长度是未读内容的长度,而不是已存在内容的总长度。它与在当前值之上的读操作和写操作都有关系,随着这两种操作的进行而改变。 Buffer值的容量:Buffer值的容量是指它的内容容器(字节切片)的容量,它只与在当前值之上的写操作有关,并会随着内容的写入而不断增长。 已读字节计数:在strings.Reader中有Size方法可以得出内容长度的值,所以用内容长度减去未读部分长度就是已读计数,但是bytes.Buffer类型没有这样的方法,它的Cap方法提供的是内容容器的容量,而不是内容的长度,大部分情况下这两者是不同的,因此很难估计Buffer值的已读计数。 已读字节计数的作用 bytes.Buffer中的已读计数的大致功能如下: 读取内容时,相应方法会根据已读计数找到未读部分,并在读取后更新计数 写入内容时,如需扩容,相应方法会根据已读计数实现扩容策略 截断内容时,相应方法截掉的是已读计数代表索引之后的未读部分 读回退时,相应方法需要用已读计数记录回退点 重置内容时,相应方法会把已读计数重置为0 导出内容时,相应方法只会导出已读计数代表的索引之后的未读部分 获取长度时,相应方法会依据已读计数和内容容器的长度,计算未读部分的长度并返回 已读计数在bytes.Buffer类型中的重要作用,大部分方法都用到它。 读取内容 相应方法先根据已读计数,判断一下内容容器中是否还有未读的内容 如果有,那么它就会从已读计数代表的索引处开始读取 读取完成后,及时更新已读计数,也就是说会记录一下又有多少字节被读取了 读取内容的相应方法:包括所有名称以Read开头的方法,已经Next方法和WriteTo方法。 写入内容 绝大多数的相应方法都会先检查当前的内容容器,是否有足够的容量容纳新的内容 如果没有,就对内容容器进行扩容 扩容的时候,方法会在必要时,依据已读计数找到未读部分,把其中的内容拷贝到扩容后的内容容器的头部位置 然后方法把已读计数的值重置为0,以表示下一次读取需要从内容容器的第一个字节开始 写入内容的相应方法:包括所有名称以Write开头的方法,以及ReadFrom方法。 截取内容 截取内容的方法Truncate,它接收一个int类型的参数,这个参数的值代表了:在截取时需要保留头部的多少个字节。 这里的头部,指的并不是内容容器的头部,而是其中未读部分的头部。头部的起始索引正是由已读计数的值表示的。在这种情况下,已读计数的值再加上参数值后得到的和,就是内容容器新的总长度。 读回退 在bytes.Buffer中,用于读回退的方法有: UnreadByte: 回退一个字节 实现方法是把已读计数减一 UnreadRune: 回退一个Unicode字符 实现方法是在已读计数中减去上一个被读取的Unicode字符所占用的字节数 这个字节数有bytes.Buffer的另一个字段负责存储,它在这里的有效取值范围是[1,4],只有ReadRune方法才会把这个字段的值设定在此范围之内 > 只有ReadRune方法之后,UnreadRune方法的调用才会成功,该方法比UnreadByte方法的适用面更窄。 调用它们一般都是为了退回上一次被读取内容末尾的那个分隔符,或者在重新读取前一个字节或字符做准备。 退回的前提是,在调用它们之前的那个操作必须是“读取”,并且是成功的读取,否则这些方法只能忽略后续操作并返回一个非nil的错误值。 bytes.Buffer的Len方法返回的是内容容器中未读部分的长度,而不是其中已存内容的总长度,该类型的Bytes方法和String方法的行为与Len方法的行为保存一直,只会访问未读部分中的内容,并返回相应的结果值。 在已读计数代表的索引之前的那些内容,永远都是已经被读过的,它们几乎没有机会再次被读取。这些已读内容所在的内存空间可能会被存入新的内容,这一般都是由于重置或者扩充内容容器导致的。这时,已读计数一定会被置为0,从而再次指向内容容器中的第一个字节。这有时候也是为了避免内存分配和重用内存空间。 扩容策略 Buffer值既可以手动扩容,也可以自动扩容,这两种扩容方式的策略基本一直,除非完全确定后续内容所需的字节数,否则让Buffer值自动扩容就好了。 扩容时,扩容代码会先判断内容容器的剩余容量,是否可以满足调用方的要求,或者是否足够容纳新的内容。如果可以,扩容代码会在当前的内容容器之上,进行长度扩充。 如果内容容器的容量与其长度的差,大于或等于另需的字节数,那么扩容代码就会通过切片操作对原有的内容容器的长度进行扩充,如下所示: b.buf = b.buf[:length+need] 如果内容容器剩余的容量不够了,那么扩容代码可能就会用新的内容容器去代替原有的内容容器,从而实现扩容。此处进行一步优化。 如果当前内容容器的容量的一半,仍然大于或等于其现有长度再加上另需的字节数的和,即cap(b.buf)/2 >= len(b.buf)+need,那么扩容代码就会服用现有的内容容器,并把容器中的未读内容拷贝到它的头部位置。这样已读的内容会被全部未读的内容和之后的新内容覆盖掉。这样的复用至少节省一次扩容带来的内存分配以及若干字节的拷贝。 若不满足优化条件,即当前内容容器的容量小于新长度的两倍。那么扩容代码就只能创建一个新的内容容器,并把原有容器中的未读内容拷贝进去,最后再用新的内容容器替换原来的。 > 新容器的容量=2×原来容量+所需字节数 通过上述步骤,对内容容器的扩容基本完成,不过为了内部数据的一致性,以及避免原有的已读内容造成的数据混乱,扩容代码会把已读计数重置为0,并再对内容容器做一次切片操作,以掩盖掉原有的已读内容。 对于处于零值状态的Buffer值来说,如果第一次扩容时的另需字节数不大于64,那么该值会基于一个预先定义好的,长度为64的字节数组来创建内容容器,这样内容容器的容量就是64,这样做的目的是为了让Buffer值在刚被真正使用的时候就可以快速地做好准备。 哪些方法可能造成内容泄露 内容泄露是指使用Buffer值的一方通过某种非标准方式等到了本不该得到的内容。 比如,通过调用Buffer值的某个用于读取内容的方法,得到了一部分未读内容,我们应该也只应该通过这个方法的结果值,拿到在那一时刻值中的未读内容,但是,在这个Buffer值又有了一些新的内容之后,却可以通过当时得到的结果值,直接获得新的内容,而不需要再次调用相应的方法。 这就是典型的非标准读取方式。这种读取方式不应存在,如果存在,不应该使用。 在bytes.Buffer中,Bytes方法和Next方法都可能会造成内容的泄露,原因在于,它们都把基于内容容器的切片直接返回给了方法的调用方。 基于切片可以直接访问和操作它的底层数组,不论这个切片是基于某个数组得来的,还是通过另一个切片操作获得的。 在这里Bytes方法和Next方法返回的字节切片,都是通过对内容容器做切片操作得到的,它们与内容容器共用同一个底层数组,起码在一段时间内是这样的。 例子 Bytes方法会返回在调用那一刻它所属值中的所以未读内容: contents := "ab" buffer1 := bytes.NewBufferString(contents) fmt.Printf("The capacity of new buffer with contents %q: %d\n", contents, buffer1.Cap()) // 内容容器的容量为:8。 unreadBytes := buffer1.Bytes() fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes) // 未读内容为:[97 98]。 buffer1.WriteString("cdefg")// 在向buffer1中写入字符串值“cdefg” fmt.Printf("The capacity of buffer: %d\n", buffer1.Cap()) // 内容容器的容量仍为:8。 // 通过简单的在切片操作,就可以利用这个结果值拿到buffer1在此时的所有未读内容 unreadBytes = unreadBytes[:cap(unreadBytes)] fmt.Printf("The unread bytes of the buffer: %v\n", unreadBytes) // 基于前面获取到的结果值可得,未读内容为:[97 98 99 100 101 102 103 0]。 // 如果将unreadBytes的值传到外界,那么就可以通过该值操纵buffer1的内容 unreadBytes[len(unreadBytes)-2] = byte('X') // 'X'的 ASCII 编码为 88。 fmt.Printf("The unread bytes of the buffer: %v\n", buffer1.Bytes()) // 未读内容变为了:[97 98 99 100 101 102 88]。 Next方法也有这样的问题。 如果经过扩容,Buffer值的内容容器或者它的底层数组被重新设定了,那么之前的内容泄露问题就无法再进一步发展了。在传出切片这类值之前要做好隔离,比如,先对它们进行深度拷贝,然后再把副本传出来。

23-io 阅读更多

io包中接口的好处与优势 提高不同程序实体之间的互操作性。 在io包中,有这样几个拷贝数据的函数:io.Copy,io.CopyBuffer,io.CopyN。这几个函数在功能上略有差别,但它们首先会接收两个参数: 用于代表数据目的地,io.Writer类型的参数dst 用于代表数据来源的,io.Reader类型的参数src 这些函数的功能大致上都是把数据从src拷贝到dst。不论给予的参数是什么类型,只要实现了这两个接口,就几乎可以正常执行。函数中还会对必要的参数值进行有效性检查,如果检查不通过,它的执行也是不能够成功结束的。 // 创建字符串读取器 src := strings.NewReader( "CopyN copies n bytes (or until an error) from src to dst. " + "It returns the number of bytes copied and " + "the earliest error encountered while copying.") // 创建字符串构造器 dst := new(strings.Builder) written, err := io.CopyN(dst, src, 58) if err != nil { fmt.Printf("error: %v\n", err) } else { fmt.Printf("Written(%d): %q\n", written, dst.String()) } 变量src和dst的类型分别是strings.Reader和strings.Builder,当它们被传入到io.CopyN函数时,就已经被包装成了io.Reader和io.Writer类型的值。 为了优化的目的,io.CopyN函数中的代码会对参数值进行在包装,也会检测这些参数值是否还实现了别的接口,甚至还会去探求某个参数值被扮装后的实际类型,是否为某个特殊的类型。从总体上来看,这些代码都是面向参数声明中的接口来做的,极大的扩展了它的适用范围和应用场景。 换个角度,正因为strings.Reader和strings.Builder类型都实现了不少接口,所以它们的值才能被使用在更广阔的场景中。Go语言的各种库中,能够操作它们的函数和数据类型明显多了很多。这就是strings和bytes包中的数据类型实现了若干个接口之后的最大好处。这是面向接口编程的最大好处。 io.Reader和ioWriter这两个核心接口是很多接口的扩展对象和设计源泉,很多数据类型实现了io.Reader接口,是因为它们提供了从某处读取数据的功能。 不少类型的设计初衷是:实现这两个核心接口的某个,或某些扩展接口,以提供比单纯的字节序列读取或写入更加丰富的功能。 在Go语言中,对接口的扩展是通过接口类型之间的嵌入来实现的,称为接口组合。Go语言提倡使用小接口加上接口组合的方式,来扩展程序的行为以及增加程序的灵活性。 io.Reader扩展接口和实现类型及其功能 在io包中,io.Reader的扩展接口: io.ReaderWriter:既是io.Reader的扩展接口,也是io.Writer的扩展接口。该接口定义了一组行为,包含且仅包含了基本的字节序列读取方法Read和字节序列写入方法Write。 io.ReaderCloser:此接口处理包含基本的字节序列读取方法之外,还有基本的关闭方法Close,一般用于关闭数据读写的通路。 io.ReadWriteCloser:三个接口的组合。 io.ReaderSeeker:此接口的特点是拥有一个用于寻找读写位置的基本方法Seek,该方法可以根据规定的偏移量基于数据的起始位置、末尾为止或者当前读写为止寻找到新的读写位置。新的读写为止用于表明下一次读或写的起始索引。Seek是io.Seeker接口唯一拥有的方法。 io.ReadWriteSeeker:是三个接口的组合。 在io包中的io.Reader接口的实现类型: *io.LimitedReader:此类型的基本类型会包装io.Reader类型的值,并提供一个外的受限读取的功能。 受限读取是指,此类型的读取方法Read返回的总数据量会收到限制,无论该方法被调用多少次,这个限制由该类型的字段N致命,单位为字节。 *io.SelectionReader:此类型的基本类型可以包装io.ReaderAt类型的值,并且会限制它的Read方法,只能怪读取原始数据中的某一个部分(或者某一段)。 这个数据段的起始位置和末尾位置,需要在初始化的时候指明,并且之后无法修改。该类型值的行为与切片类似,只会对外暴露其窗口中的那些数据。 *io.teeReader:此类型是包级私有的数据类型,是io.TeeReader函数结果值的实际类型,这个函数接受两个参数r和w,类型分别是io.Reader和io.Writer。 其结果值的Read方法会把r中的数据经过作为方法参数的字节切片p写入到w。 io.multiReader:此类型是包级私有的数据类型,类似的,io包中有一个名为MutliReader的函数,它可以接受若干个io.Reader类型的参数值,并返回一个实际类型为io.mutliReader的结果值。 这个结果值的Reader方法被调用时,他会顺序地从前面那些io.Reader类型的参数值中读取数据。称之为多对象读取器。 io.pipe:此类型是包级私有的数据类型,不但实现io.Reader接口,还实现io.Writer接口。 io.PipeReader和io.PipeWriter类型拥有的所以指针方法都以它为基础,这些方法只是代理io.pipe类型值所拥有的某一个方法而已。因为io.Pipe函数返回这两个乐行的指针值并分别把它们作为其生成的同步内存管道的两端,所以,*io.pipe类型就是io包提供的同步内存管道的核心实现。 io.Pipereader:被视为io.pipe类型的代理类型,它代理了后者的一部分功能,并基于后者实现了io.ReadCloser接口,同时还定义了同步内存管道的读取端。 io包是Go语言标准库中所有I/O相关API的根基,必须对其中的每一个程序实体都了解。 io包中的接口以及它们之间的关系 简单接口:没有嵌入其他接口并且只定义了一个方法的接口。 在io包中,这样的接口一共有11个。分为四大类:读取(5个)、写入(4个)、关闭(1个)、读写位置设定(1个)。目前三种操作属于基本的I/O操作。 核心接口:有这众多的扩展接口和实现类型。 在io包中,核心接口只有3个:io.Reader(5个扩展接口、6个实现类型)、io.Writer、io.Closer 读取 io.ByteReader和io.RuneReader分别定义了读取方法:ReadByte和ReadRune。与io.Reader接口中的Reader方法不同,这两个方法只能读取下一个单一字节和Unicode字符。 strings.Reader和strings.Buffer都是io.ByteReader和io.RuneReader的实现类型。 这两个类型还实现了io.ByteScanner接口和io.RuneScanner接口。 io.ByteScanner接口内嵌了简单接口io.ByteReader,并定义了额外的UnreadByte方法,它抽象出了可以读取和读回退单个字节的功能集。 io.RuneScanner接口内嵌了简单接口io.RuneReader,并定义了额外的UnreadRune方法,它抽象出了可以读取和读回退单个Unicode字符的功能集。 io.ReaderAt接口,其中只定义了一个方法ReadAt,与前面说过的读取方法都不同,ReadAt是一个纯粹的只读方法。只去读取其所属值总包含的字节,而不对这个值进行任何的改动,比如它绝对不能去修改已读计数的值,这是io.ReaderAt接口与其他实现类型之间最重要的一个约定。如果仅仅并发地调用某一个值的ReadAt方法,那么安全性应该是可以得到保障的。 io.WriteTo接口,其中定义了一个WriteTo方法,这是一个读取方法,它会接受一个io.Writer类型的参数值,并会把其所属值中的数据读出并写入到这个参数中。 在io包中,与写入操作有关的接口都与读取操作的相关接口有着一定的对应关系。 写入 io.ReaderFrom接口,其中定义了ReadFrom方法,这是一个写入方法,该方法会接受一个io.Reader类型的参数值,并会从该类型值中读出数据,并写入到其所属值中。 io.Writer核心接口,基于它扩展接口除了io.ReadWriter、io.ReadWriteCloser、io.ReadWriteSeeker、io.WriteCloser和io.WriteSeeker。 读写位置 io.Seeker接口作为读写位置设定相关的接口,定义了一个方法Seek。 数据类型 *os.File,这个类型不但是io.WriterAt接口的实现类型,还实现了io.ByteWritCloser和io.ReadWriteSeeker,该类型支持的I/O操作非常丰富。 总结 io包中的接口体系

24-bufio 阅读更多

bufio是buffered I/O的缩写,这个代码包中的程序实体实现的I/O操作都内置了缓冲区。bufio包中的数据类型主要有: Reader Scanner Writer ReadWriter 与io包中的数据类型类似,这些类型的值也都需要在初始化的时候,包装一个或多个简单I/O接口类型的值。(简单接口类型值的就是io包中的那些简单接口。) bufio.Reader类型值中的缓冲区的作用 bufio.Reader类型值内的缓冲区,是一个数据存储中间,它介于底层读取器(初始化此类值的时候传入io.Reader类型的参数值)与读取方法及其调用方之间。 bufio.Reader值的读取方法一般都会先从其所属值的缓冲区中读取数据,必要的时候,它们还会预先从底层读取器那里读取一部分数据,并暂存于缓冲区中以备后用。 有这样一个缓冲区的好处是,可以在大多数时候降低读取方法的执行时间,虽然读取方法有时还要负责填充缓冲区,但从总体来看,读取方法平均执行时间一般会因此有大幅的缩短。 bufio.Reader类型并不是开箱即用的,它包含一些需要显式初始化的字段,如下: // Reader为io.Reader对象实现缓冲。 type Reader struct { // 字节切片,代表缓冲区, // 虽然这是切片类型,但是它的长度却是在初始化的时候指定,并且在之后保持不变 buf []byte rd io.Reader //客户端提供的reader,代表底层读取器,缓冲区中的数据就是从这里拷贝来的 r, w int // buf 读写位置 // r 代表对缓冲区进行下一次读取时的开始索引,称为已读计数 // w 代表对缓冲区进行下一次写入时的开始索引,称为已写计数 // 它的值用于表示在从底层读取器获得数据时发生的错误 // 这里值在被读取或忽略之后,该字段会被设置为nil err error // UnreadByte读取的最后一个字节; -1表示无效 // 用于记录缓冲区中最后一个被读取的字节,读回退时会用到它的值 lastByte int // UnreadRune读取的最后一个rune的大小; -1表示无效 // 用于记录缓冲区中最后一个被读取的Unicode字符所占用的字节数, // 读回退的时候会用到它的值,这个字段只会在其所属值的ReadRune方法中才会被赋予有意义的值 // 其他情况下,它都被置为-1 lastRuneSize int } bufio包提供了两个用于初始化Reader值的函数: NewReader:初始化的Reader值会拥有一个默认大小(4096字节,即4KB)的缓冲区, NewReaderSize:将缓冲区的大小的决定权交给使用方 它们都会返回一个*bufio.Reader类型的值。这里的缓冲区在一个Reader值的生命周期内大小是不变的,所以在有些时候需要做一些权衡。 读取Peek和ReadSlice方法,都会调用该类型的一个名为fill的包级私有方法,fill方法的作用是填充内部缓冲区。 fill方法,首先检查其所属值的已读计数,如果这个计数不大于0,那么有两种可能: 缓冲区中的字节都是全新的,它们没有被读取过 缓冲区刚被压缩过,对缓冲区的压缩操作: 把缓冲区中在[已读计数,已写计数]范围之内的所有字节都一次拷贝到缓冲区的头部,这一步不会有副作用,因为: 已读计数之前的字节都已经被读取过,肯定不会再被读取,因此把它们覆盖掉是安全的 在压缩缓冲区之后,已写计数之后的字节只可能是已经被读取过的字节,或者是已被拷贝到缓冲区头部的未读字节,或者是代表未曾被填入数据的零值(0x00),所以后续的新字节可以被卸载这些位置上。 fill方法会把已写计数的新值设定为原已写计数与已读计数只差,这个差锁代表的索引,就是压缩后第一次写入字节时的开始索引。 缓冲区的压缩过程,如下图所示: 实际上,fill方法只要在开始时发现其所属值的已读计数大于0,就会对缓冲区进行一次压缩,之后,如果缓冲区中还有可写的位置,那么该方法就会对其进行填充。 在填充缓冲区的时候,fill方法会试图从底层读取器哪里,读取足够多的字节,并尽量把从已写计数代表的索引位置到缓冲区末尾之间的空间都填满。 在这个过程中fill方法会及时更新已写计数,以保证填充的正确性和顺序性,它还会判断从底层读取器读取数据的时候,是否有错误发生,如果有,那么它就会把错误值赋予给其所属值的err字段,并终止填充流程。 bufio.Writer类型值中缓冲的数据何时写入底层写入器 // Writer为io.Writer对象实现缓冲。 // 如果在写入Writer时发生错误,将不再接受任何数据, // 并且所有后续写入和Flush都将返回错误。 // 写入所有数据之后,客户端应调用Flush方法以确保所有数据都已转发到底层io.Writer。 type Writer struct { err error // 它的值用于表示在向底层写入器写数据时发生的错误 buf []byte // 代表缓冲区,在初始化之后,它的长度会保持不变 n int // 代表对缓冲区进行下一次写入时的开始索引,称为写入计数 wr io.Writer // 代表底层写入器 } bufio.Writer类型有一个名为Flush的方法,它的主要功能是把相应缓冲区中暂存的所以数据,都写到底层写入器中,数据一旦被写入底层写入器,该方法就会把它们从缓冲区中删除掉。 这里的删除有时候只是逻辑删除。不论是否成功写入了所有暂存数据,Flush方法都会妥当处置,并保证不会出现重写或者漏写的情况。 bufio.Writer类型拥有的所以数据写入方法都会在必要的时候调用它的Flush方法。 Write方法有时候会在把数据写进缓冲区之后,调用Flush方法,以便为后续的新数据腾出空间,如果Write方法发现要写入的字节太多,同时缓冲区已空,那么会直接跨过缓冲区,直接把新的数据写到底层写入器中。 WriteByte方法和WriteRune方法都会在发现缓冲区中的可写空间不足以容纳新的字节或Unicode字符的时候,调用Flush方法 ReadFrom方法,会在发现底层写入器的类型是io.ReaderFrom接口的实现之后,直接调用其ReadFrom方法把参数值持有的数据写进去。 只要缓冲区中的可写空间无法容纳需要写入的新数据,Flush方法就一定会被调用,bufio.Writer类型的一些方法有时候还会试图走捷径,跨过缓冲区而直接对接数据供需方。 bufio.Reader类型的读取方法 bufio.Reader类型拥有很多用于读取数据的指针方法,这里有四个方法可以作为不同读取流程的代表: func (b *Reader) Peek(n int) ([]byte, error) Peek:读取并返回其缓冲区中n个未读字节,并且它会从已读计数代表的索引位置开始读。 在缓冲区未被填满,并且其中的未读字节的数量小于n的时候,该方法会调用fill方法,以启动缓冲区填充流程,如果发现上次填充缓冲区时有错误,则不再填充。 如果调用方给定的n比缓冲区的长度还大,或者缓冲区中未读字节的数量小于n,那么: 所有未读字节组成的序列作为第一个结果 bufio.ErrBufferFull变量的值作为第二个结果,用来表示虽然缓冲区被压缩和填满了,但是仍然不满足要求 上述情况都未出现,则返回已读计数为起始的n个字节和表示未发生任何错误的nil Peek方法的一个特点,即使它读取了缓冲区中的数据,也不会改变已读计数的值。其他的读取方法不是这样的。 func (b *Reader) Read(p []byte) (n int, err error) Read:把缓冲区中的未读字节,依次拷贝到其参数p代表的字节切片中,并立即根据实际拷贝的字节数增加已读计数的值。 在缓冲区中还有未读字节的情况下,Read方法是这样做的。(当已读计数等于已写计数时,表示此时的缓冲区中没有任何未读的字节) 当缓冲区中无未读字节时,Read方法会先检查参数p的长度是否大于或等于缓冲区的长度。 如果是,Read方法放弃缓冲区中的填充数据,直接从底层读取器中读出数据并拷贝到p中,这意味着它完全跨过了缓冲区,并直连了数据供需的双方。 如果否,会先把已读计数和已写计数都重置为0,然后再尝试(只进行一次)使用从底层读取器那里回去的数据,对缓冲区进行一次从头到尾的填充。 func (b *Reader) ReadSlice(delim byte) (line []byte, err error) ReadSlice:持续地读取数据,直到遇到调用方给定的分隔符为止。 先在缓冲区的未读部分中寻找分隔符,如果未找到,并且缓冲区未满,那么调动fill方法对缓冲区进行填充,然后再次寻找,如此往复。 如果在填充的过程中遇到错误,会把未读部分作为结果返回,并返回相应的错误值。 如果缓冲区被填满,仍然没有找到分隔符,那么整个缓冲区作为第一个结果,bufio.ErrBufferFull(缓冲区已满的错误)作为第二个结果 func (b *Reader) ReadBytes(delim byte) ([]byte, error) ReadBytes:持续地读取数据,直到遇到调用方给定的分隔符为止。 ReadBytes方法依赖ReadSlice方法。 ReadLine方法依赖ReadSlice方法。 ReadString方法完全依赖ReadBytes方法,只是在返回的结果之上做简单的类型转换。 Peek、ReadSlice、ReadLine方法都可能会造成内容泄露,在正常情况下,它们都会直接返回基于缓冲区的字节切片,调用方可以通过这些方法的结果值访问到缓冲区的其他部分,甚至修改缓冲区中的内容,这是非常危险的。

25-os 阅读更多

os包提供操控计算机操作系统的能力,都是与平台不相关的API。 平台不相关API:这些API基于(或者说抽象自)操作系统,为我们使用操作系统的功能提供高层次的支持,但是,它们并不依赖于具体的操作系统。 不论是什么操作系统,os包都提供统一的使用接口,使得我们可以用同样的方式,来操纵不同的操作系统,并得到相似的结果。其中的API帮助使用操作系统的: 文件系统:操作文件系统的API最丰富 os.File数据类型,代表了操作系统中的文件(对于Unix操作系统,万物皆文件),除了常见的文本文件、二进制文件、压缩文件、目录等,还有符号链接、各种物理设备(内置或外界的面向块或者字符的设备)、命名管道、套接字(socket)等。 权限系统 环境变量 系统进程 系统信号 os.File类型实现了哪些io包的接口 os.File类型拥有的都是指针方法,所以除了空接口,它本身没有任何接口,它的指针类型实现了很多io包中的接口。 *os.File类型实现了io.Reader、io.Writer、io.Closer、io.ReadAt、io.Seeker、io.WriterAt。没有实现io.ByteReader和io.RuneReader。 os.File类型以何种方式操作文件 获取os.File类型的指针值的方法,这些方法都执行同一个系统调用,并且在成功之后得到一个文件描述符,这个文件描述符会被存储在它们返回的File值中: func Create(name string) (*File, error) Create:根据给定的路径创建一个新的文件,返回一个File值和一个错误值(可能为非nil的错误值),对该函数返回的File值对应的文件进行读写操作。 该函数创建的文件,对操作系统的所有用户都是可读写的 如果函数的路径上已经存在了一个文件,那么会清空文件中全部的内容,然后把它作为第一个结果值返回 func NewFile(fd uintptr, name string) *File NewFile:在被调用时,需要接受一个代表文件描述符的uintptr类型的值,以及一个用于表示文件名的字符串值 如果给定的文件描述符并不有效,那么函数会返回nil 否则返回一个代表文件的File值 这个函数的功能并不是创建一个新的文件,而是依据已存在的文件的描述符,来创建一个包装了该文件的File值。如下所示,获取一个包装了标准错误输出的File值,然后通过这个File值向标准错误输出中写入一些内容: file3 := os.NewFile(uintptr(syscall.Stderr), "/dev/stderr") if file3 != nil { defer file3.Close() file3.WriteString("The Go language program writes the contents into stderr.\n") } func Open(name string) (*File, error) Open:打开一个文件,并返回包装了该文件的File值。该函数只能以只读模式打开文件,也就是只能读取文件内容,不能写入内容。如果调用这个File值的任何写入方法,都会得到“坏的文件描述符”的错误值。这个只读模式应用与File值所持有的文件描述符上。 文件描述符,是又通常很小的非负整数代表的,它一般会由I/O相关的系统调用返回,并作为某个文件的一个标识存在。 从操作系统层面看,针对任何文件的I/O操作都需要用到这个文件描述符。只不过Golang中的一些数据类型,我们隐藏掉了这个描述符,如此一来,就不需要时刻关注和辨别它,如os.File类型。 os.File类型有一个指针方法,叫Fd,它在被调用之后将会返回一个uintptr类型的值,这个值代表了当前File值所持有的那个文件描述符。在os包中,只有NewFile函数需要用到它,它也没有别的用武之地,如果只是操作常规文件和目录,无需特别注意它。 func OpenFile(name string, flag int, perm FileMode) (*File, error) OpenFile:这个函数是os.Create和os.Open函数的底层支持,它最为灵活。 这个函数的三个参数: name:表示文件的路径 flag:需要施加在文件描述符之上的模式(如,只读模式os.O_RDONLY,它是int类型的),称为操作模式,限定操作文件的方式 perm:也是模式,它的类型是os.FileMode(此类型是一个基于unit32类型的再定义类型),称为权限模式,控制文件的访问权限 通过os.File类型的值,我们不但可以对文件进行读取、写入、关闭等操作,还可以设定下一次读取或写入是的起始索引位置。os包中: 用于常见权限文件的Create函数 用户包装现存文件的NewFile函数 用以打开已存在的文件的Open函数和OpenFile函数 File的操作模式 针对File值的操作模式主要有: 只读模式:os.O_RDONLY 只写模式:os.O_WRONLY 读写模式:os.O_RDWR 在我们新建或者打开一个文件的时候,必须把这三种模式中的一个设定为此文件的操作模式。 还可以为文件设置额外的操作模式: os.O_APPEND:当向文件中写入内容时,把新内容追加到现有内容的后边 os.O_CREATE:当给定路径上的文件不存在时,创建一个新的文件 os.O_EXCL:需要与os.O_CREATE一同使用,表示在给定路径上不能有已存在的文件 os.O_SYNC:在打开的文件之上实施同步I/O,它会保证读写的内容总会与硬盘上的数据保持同步 os.O_TRUNC:如果文件已存在,并且是常规文件,那么就先情况其中已经存在的任何内容 例子 // Create函数 func Create(name string) (*File, error) { return OpenFile(name, O_RDWR|O_CREATE|O_TRUNC, 0666) // 给予的操作模式是 O_RDWR|O_CREATE|O_TRUNC的组合 // 如果参数name代表的路径之上的文件不存在那么就会新建一个 // 否则先情况现存文件中的全部内容 // 返回的File值的读方法和写方法都能可用 } // Open函数 // 以只读模式打开已存在的文件 func open(name string)(*File, error){ return OpenFile(name, O_REONLY, 0) } 注意,多个操作模式通过桉位或操作符组合起来。 File的权限模式 os.FileMode类型不但可以代表权限模式,还能代表文件模式(即,文件种类)。os.FileMode是基于uint32类型的在定义类型,所以它的每个值都包含了32个比特位,每个比特位都有特定的含义。 在一个os.FileMode类型值中,只有最低的9个比特位才用于表示文件权限(3个一组,共分为3组),当我们拿到一个此类型的值时,可以把它和os.ModePerm常量的值做按位与操作。这样就可以得到FileMode中所有用于表示文件权限的比特位,即权限模式。 例如,常量0777,是一个八进制无符号整数,它的最低9个比特位都是1,更高的23个比特位都是0. 最高比特位为1,该值的文件模式等同于os.ModeDir,代表一个目录 第26个比特位为1,告知的文件模式等同于os.ModeNamedPipe,代表一个命名管道 从高到底,这3组分别: 表示文件所有者(创建文件的用户) 文件所有者所属的用户组 其他用户对该文件的访问权限 每组中的3个比特位从高到底分别表示: 读权限 写权限 执行权限 如果在其中的某个比特位上的是1,就意味着相应的权限开启,否则,就标书相应的权限关闭。 在调用os.OpenFile函数的时候,可以根据上面的说明设置第三个参数,但是需要注意,只有在新建文件的时候,这里的第三个参数才是有效的,在其他情况下,即使设置了此参数,也不会对目标文件产生任何的影响。

26-net 阅读更多

socket与IPC socket是一种IPC方法(Inter-Process Communication,进程间通信),IPC主要定义的是多个进程之间,相互通信的方法,这些方法主要包括: 系统信号(signal) 管道(pipe) 套接字(socket) 文件锁(file lock) 消息队列(message queue) 信号灯(semaphore,或称为信号量)等 主流操作系统大都对IPC提供了强力的支持,尤其是socket。 Golang对IPC也提供了一定的支持,如: os包和os/signal包中针对系统信号的API os.Pipe函数可以创建命名管道 os/exec包对匿名管道提供支持 net包对socket提供支持 在众多的IPC方法中,socket是最为通用和灵活的一种,与其他的IPC方法不同,利用socket进行通信的进程,可以不局限于同一台计算机当中。通信双方只要能够通过计算机的网卡端口以及网络进行通信,就可以使用socket。 支持socket的操作系统一般都会对外提供一套API。跑在它们之上的应用程序利用这套API,就可以与互联网上的另一台计算机中的程序、同一台计算机中的其他程序,甚至同一个程序中的其他线程进行通信。 Linux操作系统中,用于创建socket实例的API,就是一个名为socket的系统调用,这个系统调用是Linux内核的一部分。 所谓系统调用,可以理解为特殊的C语言函数,它们是连接应用程序和操作系统内核的桥梁,也是应用程序使用操作系统功能的唯一渠道。 syscall包中有一个与socket系统调用相对应的函数,这两者的函数签名基本一致,都会接收三个int类型的参数,并会返回一个可以代表文件描述符的结果。 func Socket(domain, typ, proto int) (fd int, err error) syscall包中的Socket函数本身是与平台不相关的,在其底层,Go语言为它支持的每个操作系统都做了适配,这才使得这个函数无论在哪个平台上,总是有效的。 net包中的很多程序实体都会直接或间接地使用syscall.Socket函数 调用net.Dial函数的时候会为它的两个参数设定值,第一个参数名为network,它决定了Go程序底层会创建什么样的socket实例,并使用什么样的协议与其他程序通信 net.Dial函数的一个参数network的可选值 func Dial(network, address string) (Conn, error) net.Dial函数接受两个参数,network和address,都是string类型的。 network参数常用的可选值有9个,这些值分别代表了socket实例可使用的不同通信协议: tcp:代表TCP协议,其基于的IP协议的版本根据参数address的值自适应 tcp4:代表基于IP协议第四版的TCP协议 tcp6:代表基于IP协议第六版的TCP协议 udp:代表UDP协议,其基于的IP协议的版本根据参数address的值自适应 udp4:代表基于IP协议第四版的UDP协议 udp6:代表基于IP协议第六版的UDP协议 unix:代表Unix通信域下的一种内部socket协议,以SOCK_STREAM为socket类型 unixgram:代表Unix通信域下的一种内部socket协议,以SOCK_DGRAM为socket类型 unixpacket:代表Unix通信域下的一种内部socket协议,以SOCK_SEQPACKET为socket类型 syscall.Socket函数接受的三个参数 func Socket(domain, typ, proto int) (fd int, err error) 这三个参数都是int类型,这些参数代表分别是: domain:socket通信域,主要有: IPv4域:基于IP协议第四版的网络(syscall中的常量AF_INET表示) IPv6域:基于IP协议第四版的网络(syscall中的常量AF_INET6表示) Unix域:一种类Unix操作系统中特有的通信域,装有此类操作系统的同一台计算机中,应用程序可以基于此域创建socket连接(syscall中的常量AF_UNIX表示) typ:类型,共有四种: SOCK_DGRAM:代表datagram即数据报文,一种有消息边界,但没有逻辑连接的非可靠socket类型,基于UDP协议的网络通信属于此类。 有消息边界指的是,与socket相关的操作系统内核中的程序在发送或接收数据的时候是以消息为单位的。把消息理解为带有固定边界的一段数据,内核程序自动识别和维护这种边界,在必要的时候,把数据切割成一个个消息,或者把多个消息串接成连续的数据,这样应用程序只需要面向消息进处理就可以了。 有逻辑连接指的是,通信双发在收发数据之前必须先建立网络连接,待连接建立好之后,双方就可以一对一地进行数据传输,基于UDP协议的网络通信是没有逻辑连接的。只要应用程序指定好对方的网络地址,内核程序就可以立即把数据报文发送出去。 优势:发送速度快,不长期占用网络资源,并且每次发送都可以指定不同的网络地址。 劣势:每次都需要指定网络地址使得数据报文更长,无法保证传输的可靠性,不能实现数据的有序性,数据只能单向进行传输。 SOCK_STREAM:与SOCK_DGRAM相反,它是没有消息边界,但有逻辑连接,能够保证传输的可靠性和数据的有序性,同时还可以实现数据的双向传输。基于TCP协议的网络通信属于此类。 这样的网络通信传输数据的形式是字节流(字节流是以字节为单位的),而不是数据报文。内核程序无法感知一段字节流中包含了多少个消息,以及这些消息是否完整,这完全需要应用程序自己把控。 此类网络通信中的一端,总是会忠实地按照另一端发死你个数据时的字节排序,接收和缓存它们,所以应用程序需要更具双方的约定去数据中查找消息边界,并按照边界切割数据。 SOCK_SEQPACKET SOCK_RAW syscall包中都有同名常量与之对应。 proto:协议;表示socket实例所使用的协议,通常明确了前两个参数,就不在需要确定第三个参数值了,一般设置为0即可,内核程序会自动选择最适合的协议。 当两个参数分别为syscall.AF_INET和syscall.SOCK_DGRAM的时候,内核程序会选择UDP作为协议 当两个参数分别为syscall.AF_INET6和syscall.SOCK_STREAM的时候,内核程序会选择TCP作为协议 在使用net包中的高层次API的时候,前两个参数(domain和typ)也不需要给定,只需要把前面罗列的9个可选值字符串字面量的其中一个,作为network参数的值就好了。 调用net.DialTimeout函数时设定超时时间 func DialTimeout(network, address string, timeout time.Duration) (Conn, error) 超时时间代表这函数为网络连接建立完成而等待的最长时间,这是一个相对时间,由函数的参数timeout的值表示。 开始的时间点几乎是调用net.DialTimeout函数的那一刻,之后的时间,主要会花费在: 解析参数network和address的值:在这个过程中,函数会确定网络服务的IP地址、端口号等必要信息、并在需要时访问DNS服务。 如果解析出的IP地址有多个,那么函数会串行或并发地尝试建立连接,无论以什么方式尝试,函数总会以最先建立成功的那个连接为准。会根据超时前剩余的时间,去设定每次连接尝试的超时时间,以便让它们都有适当的时间执行。 创建socket实例并建立网络连接。 不论执行到哪一步,只要绝对的超时时间到达的那一刻,网络连接还没有建立完成,该函数就会返回一个代表I/O操作超时的错误值。 net包中有一个名为Dialer的结构体类型,该类型有一个Timeout字段,与上述timeout参数的含义完全一致,实际上,net.DialTimeout函数正是利用了这个类型的值才得以实现功能的。 net/http 使用net.Dial和net.DialTimeout函数访问基于HTTP协议的网络服务是完全没有问题的,HTTP协议是基于TCP/IP协议栈,并且是一个面向普通文本的协议。如果需要方便的访问基于HTTP协议的网络服务,则使用net/http包。其中最便捷的是使用http.Get函数,在调用它的时候只需要传入一个URL就可以,如下所示: url1 := "http://google.cn" fmt.Printf("Send request to %q with method GET ...\n", url1) resp1, err := http.Get(url1) if err != nil { fmt.Printf("request sending error: %v\n", err) } defer resp1.Body.Close() line1 := resp1.Proto + " " + resp1.Status fmt.Printf("The first line of response:\n%s\n", line1)func (c *Client) Get(url string) (resp *Response, err error) http.Get函数返回两个结果值: 第一个结果值的类型是*http.Response,它是网络服务给我们传回的函数内容的结构化表示 第二个结果值是error类型,它代表了在创建和发送HTTP请求,以及接收和解析HTTP相应的过程中可能发生的错误 http.Get函数会在内部使用缺省的HTTP客户端,并且调用它的Get方法以完成功能,这个缺省的HTTP客户端由net/http包中的公开变量DefaultClient代表,其类型是*http.Client,它的基本类型也是可以被拿来使用的,甚至它是开箱即用的,如下代码所示: var httpClient1 http.Client resp2, err := httpClient1.Get(url1) // 等价于 resp1, err := http.Get(url1) http.Client是一个结构体类型,并且它包含的字段都是公开的。之所以该类型的零值仍然可用,是因为它的这些字段要么存在着相应的缺省值,要么其零值直接可以使用,且代表着特定的含义。 http.Client类型中Transport字段的含义 http.Client类型中的Transport字段代表着:向网络服务发送HTTP请求,并从网络服务接收HTTP相应的操作过程。也就是说,该字段的方法RoundTrip应该实现单次HTTP事务(或者说基于HTTP协议的单次交互)需要的所有步骤。 这个字段是http.RoundTripper接口类型的,它有一个由http.DefaultTransport变量代表的缺省值,当我们在初始化一个http.Client类型的值的时候,如果显式地为该字段赋值,那么这个Client值就会直接使用http.DefaultTransport。 DefaultTransport DefaultTransport的实际类型是*http.Transport,后者即为http.RoundTripper接口的默认实现,这个类型是可以被复用的,它是并发安全的,因此http.Client类型也拥有同样的特质。 http.Transport类型会在内部使用一个net.Dialer类型的值,并且会把该值的Timeout字段的值,设定为30秒。如果30秒之内没有建立好连接就会判断为操作超时。在DefaultTransport的值被初始化的时候,这样的net.Dialer值的DialContext方法会被赋给前者的DialContext字段。 http.Transport类型还包含很多其他的字段,其中有一些字段是关于操作超时的: IdleConnTimeout:空闲的连接在多久之后就应该被关闭。 DefaultTransport会把该字段的值设置为90秒,如果该值设置为0 ,那么就表示不关闭空闲的连接。这样会造成资源的泄露。 与该字段相关的一些字段: MaxIdleConns:无论当前的http.Transport类型的值访问了多少个网络服务,这个字段都只会对空闲连接的总数做出限定。 MaxIdleConnsPerHost:这个字段限定的是http.Transport值访问的每一个网络服务的最大空闲连接数。 每个网络服务都会有自己的网络地址,可能会使用不同的网络协议,对一些HTTP请求也可能会使用代理,http.Transport值就是通过这三方面的具体情况,来鉴别不同的网络服务的。MaxIdleConnsPerHost字段的缺省值,由http.DefaultMaxIdleConnsPerHost变量代表,值为2。即,在默认情况下,对某个http.Transport值访问的每一个网络服务,它的空闲连接数最多只能有两个。 MaxConnsPerHost:针对某个http.Transport值访问的每一个网络服务的最大连接数,不论这些连接是否空闲的,该字段没有相应的缺省值,它的零值表示不对此设限制。 ResponseHeaderTimeout:从客户端把请求完全递交给操作系统到从操作系统那里接收到响应报文头的最大时长。 DefaultTransport没有设定该字段的值。 ExpectContinueTimeout:在客户端递交了请求报文头之后,等待接收第一个响应报文头的最长时间。 在客户端想要使用HTTP的POST方法把一个很大的报文体发送给服务端的时候,它可以先通过发送一个包含了Expect:100-continue的请求报文头,来询问服务端是否愿意接收这个大报文体。 这个字段是用于设定在这种情况下的超时时间的,注意,如果该字段的值不大于0 ,那么无论多大的请求报文都将会立即发送出去,这可能会造成网络资源的浪费。DefaultTransport把该字段的值设定为1秒。 TLSHandshakeTimeout:(TLS是Transport Layer Security的缩写,翻译为传输层安全),这个字段代表了基于TLS协议的连接在被建立时的握手阶段的超时时间。若该值为0 ,则表示对这个时间不设限。 DefaultTransport把该字段的值设定为10秒。 产生空闲连接的原因:HTTP协议有一个请求报文头叫做“Connection”,在HTTP 1.1 中,这个报文头的值默认是“keep-alive”,在这种情况下的网络连接都是持久连接,它们会在当前的HTTP事物完成后仍然保持着连通性,因此是可以被复用的,那就有两种可能: 针对同一个网络服务,有新的HTTP请求被递交,该连接被再次使用 不再有针对该网络服务的HTTP请求,该连接被闲置(这就会产生空闲的连接) 如果分配给某个网络服务的连接过多的话,也可能会导致空闲连接的产生,因为每一个新递交的HTTP请求,都只会征用一个空闲的连接,所以为空闲连接设定限制,在大多数情况下是很有必要的。 如果要杜绝空连接产生,可以在初始化http.Transport值的时候把它的DisableKeepAlives字段的值设置为true,这时HTTP请求的“Connection”报文头的值就会被设置为“close”,这会告诉网络服务,这个网络连接不必保持,当前的HTTP事物完成后就可以断开它了。这样每一个HTTP请求被递交时,就会产生一个新的网络连接,明显加重网络服务以及客户端的负载,并会让每个HTTP事物都消耗更多的时间。一般情况下不会设置DisableKeepAlives。 在net.Dialer类型中也有一个keepAlive字段,它是直接作用在底层的socket上的,一种针对网络连接(TCP连接)的存活探测机制。它的值用于表示每隔多长时间发送一次探测包,当该值不大于0是,则表示不开启这个机制。DefaultTransport会把这个字段的值设定为30秒。 http.Server类型的ListenAndServer方法 http.Server代表的是基于HTTP协议的网络服务,它的ListenAndServer方法的功能是:监听一个基于TCP协议的网络地址,并对接收到的HTTP请求进行处理。这个方法会默认开启针对网络连接的存活探测机制,以保证连接是持久的。该方法会一直执行,直到有严重的错误发送或者被外界关闭。当被外界关闭时,会返回一个由http.ErrServerClosed变量代表的错误值。 ListenAndServer方法主要做下面几件事情: 检查当前的http.Server类型的值的Addr字段,该字段的值代表了当前的网络服务需要使用的网络地址,即IP地址和端口号。 如果该字段的值为空字符串,那么就用“:http代替,也就是使用任何可以代表本机的域名和IP地址,并且端口号为80。 通过调用net.Listen函数在已确定的网络地址上启动基于TCP协议的监听。 检查net.Listen函数返回的错误值,如果该错误值不是nil,那么直接返回该值,否则通过调用当前值的Serve方法准备接收和处理将要到来的HTTP请求。 net.Listen函数完成如下操作: 解析参数值中包含的网络地址隐含的IP地址和端口号 根据给定的网络协议,确定监听的方法,并开始进行监听

测试规则与流程 阅读更多

对于程序或软件的测试分为很多种: 单元测试 API测试 集成测试 灰度测试 单元测试 单元测试也称为程序员测试,就是程序员本该做的自我检查工作之一。 Go程序提供了丰富的API和工具,可以创建测试源码文件,并为命令源码文件和库源码文件中的程序实体编写测试用例。在Go语言中,一个测试用例往往会由一个或多个函数来代表,大多数情况下,每个测试用例仅用一个测试函数(往往用于描述某个程序实体的某方面功能)就足够了。 Go程序可以编写三类测试: 功能测试(test): 基准测试(benchmark,性能测试): 示例测试(example):也是功能测试的一种,更关注程序打印出来的内容 一般情况下,一个测试源码文件只会针对某个命令源码文件或者库文件做测试,所以应该把它们放在一个代码包中。测试源码文件的主名称以被测源码文件的主名称前导,并且,必须以“_test”为后缀。如demo.go和demo_test.go。 每个测试源码文件都必须包含一个测试函数,用来做任何一类测试,通常把三类测试都放在一起,把控好测试函数的分组和数量即可。分组依据: 依据测试函数针对的不同程序实体,把它们分成不同的逻辑组,利用注释以及帮助类的变量或测试函数来做分割 依据被测源码文件中程序实体的先后顺序,来安排测试源文件中测试函数的顺序 测试函数的名称和签名的规定 功能测试函数:名称必须以Test为前缀,并且参数列表中只应有一个×testing.T类型的参数声明 性能测试函数:名称必须以Benchmark为前缀,并且唯一参数的类型必须是×testing.B类型 示例测试函数:名称必须以Example为前缀,函数的参数列表没有强制规定 go test执行的流程 go test命令运行的前置条件: 测试源码文件的名称对了 测试函数名称和签名对了 go test 流程: 准备工作:确定内部需要用到的命令,检查制定的代码包和源码文件的有效性,判断给予的标记是否合法 开始执行:针对每个被测试代码包,依次进行构建、执行包中符合要求的测试函数,清理临时文件,打印测试结果 注意是依次,对每个被测代码包go test命令会串行地执行测试流程中的每个步骤。但是为了加快测试速度,通常会并发地对多个被测代码包进行功能测试,只不过在最后打印测试结果的时候,会依照给定的测试逐个进行。并发地测试会让性能测试的结果出现偏差,所以性能测试一般是串行的(只要在所有构建步骤都做完之后,go test命令才会真正开始进行性能测试)。 Go语言是一门重视测试的语言,不但自带testing包,还有专用于程序测试的go test命令。要想真正用好一个工具,必须先了解它的核心逻辑。 功能测试 // 第一次的测试结果 ok puzzlers/article20/q2 0.008s // 第二次的测试结果 ok puzzlers/article20/q2 (cached) 测试结果分为三个部分: ok:表示此次测试成功 被测代码包的导入路径 对该代码包的测试消耗的时间 再次运行测试命令,在测试结果中第三部分不再显示测试时间,而是显示(cached)。 由于测试代码和被测试代码没有变化,所以go test会直接将缓存测试成功的结果打印出来。 一旦有任何改动,缓存数据失效,go命令再次真正的执行操作。 go 命令通常会缓存程序构建的结果,以便在将来的构建中重用。运行go env GOCACHE命令来查看缓存目录的路径。缓存的数据总是能够正确地放反映出当时的各种源码文件、构建环境、编译器选项等的真实情况。 go 命令会定期清楚最近未使用的缓存数据,执行go clean -cache命令手动删除缓存数据,执行go clean -testcache手动删除所有的测试结果缓存。 设置环境变量GODEBUG的值可以改变go命令的缓存行为。比如设置gocacheverify=1将导致go命令绕过任何的缓存数据,真正的执行操作并重新生成所有结果,然后再去检查新的结果与缓存数据是否一致。