使用goroutine的几种方法

在golang中,使用go关键字可以创建一个goroutine。那么怎么使用goroutine满足各种不同的需求呢?

一. 直接使用go关键字运行函数:

func greet(name string) {
  fmt.Printf("Hello %s!\n", name)
}

func main() {
  go greet("World")
  time.Sleep(1 * time.Second)
}

这种方法是最常用的方法,将要使用协程运行的任务编写到一个函数中,在需要的时候使用go关键字执行就可以了,需要注意的是,如果没有并发控制,那么一定要给协程的执行留出充分的时间,否则协程还没有处理完,有可能程序就已经退出了。

二. 使用匿名函数创建协程

func main() {
  go func (name string) {
    fmt.Printf("Hello %s!\n", name)
  } ("World")
  time.Sleep(1 * time.Second)
}

这种方法也是非常常用的,使用匿名函数的好处,首先是需要的地方单独定义,适合不需要复用的逻辑,另外匿名函数不需要占用命名,对于解决程序员三大难题还是很有帮助的。

三. 匿名函数直接访问主调函数中的变量

你有可能已经想到了,在使用匿名函数的方法中,匿名函数内部实际上是可以访问主调函数空间中的变量的。也就是说,我们不需要使用参数传递,就可以让协程获取到需要的参数。

func main() {
  var name = "World"
  go func() {
    fmt.Printf("Hello %s!\n", name)
  } ()
  time.Sleep(1 * time.Second)
}

请注意,这种方法有一个坑,因为协程中可以直接访问主调函数中的变量,而不是通过参数传递获取,这样就跳过了拷贝变量的步骤,协程中访问变量就是实实在在的主调函数中的变量,有些时候程序可能会和你的预期有些区别。比如:

func main() {
  var name = "World"
  go func() {
    fmt.Printf("Hello %s!\n", name)
  } ()
  name = "me"
  time.Sleep(1 * time.Second)
}

执行上面的程序,很大概率会打印出“Hello me!” 而不是 “Hello World!”,这是因为创建协程需要时间,并且协程调度不是我们编写的程序能够控制的。如果在打印的时候,name的内容没有变化,协程就会打印出“Hello World!”,而如果name的值已经变化了,那么打印出来的东西也会发生变化。同时,除了主调函数的值影响协程 ,协程对变量的修改也会直接影响主调函数,最大的问题是,没有并发控制的情况中,你无法知道协程有没有修改主调函数中的变量。比如:

func main() {
  var a = 1
  go func() {
    a = 2
  } ()
  fmt.Print("1+1 = " )
  fmt.Println(a + a)
  time.Sleep(1 * time.Second)
}

如果协程执行的很晚,那么我们可能打印出正确的内容,如果协程执行的很快,那么就会打印出 “1+1 = 4”这个错误的答案。

四. 使用sync.WaitGroup进行协程控制

func main() {
  var a = 1
  wg := sync.WaitGroup{}
  wg.Add(1)
  go func() {
   defer wg.Done()
    a = 2
  } ()
  wg.Wait()
  fmt.Print("2+2 = " )
  fmt.Println(a + a)
  time.Sleep(1 * time.Second)
}

为了准确的控制工作的流程,我们使用sync.WaitGroup进行协程同步,wg.Add(1)声明一个会执行的协程,wg.Done()表明有一个协程执行完成了。wg.Wait()会在协程退出前保持阻塞,只有协程结束之后,打印工作才会进行,这样就能打印出正确的 “2+2 = 4”的结果。

五. 使用循环创建多个协程

我们使用协程的目的就是为了效率,如果每次都只创建一个协程,那么对于提到处理效率并没有什么帮助。所以通常使用协程都会配合循环创建多个协程。

func main() {
  var names = []string{"Alice", "Bob", "Charlie", "Dave", "Eve"}
  for i := range names {
    go func() {
      fmt.Printf("Hello %s!\n", names[i])
    } ()
  }
  time.Sleep(1 * time.Second)
}

执行上面的程序你可能会打印出5个相同的Hello Eve!是的,我们再一次踩到了使用匿名函数的坑。因为i也是主调函数中的变量,所以在协程中执行打印的时候,i其实已经变化了,这个例子中创建的协程比较少,大概率在所有协程执行执行的时候,i已经变成最终的值 4 了。那么如何解决这个问题呢?我们可以在创建协程之前将它拷贝一遍,这样每个协程就可以使用一个稳定的值了。

func main() {
  var names = []string{"Alice", "Bob", "Charlie", "Dave", "Eve"}
  for i := range names {
    index := i
    go func() {
      fmt.Printf("Hello %s!\n", names[index])
    } ()
  }
  time.Sleep(1 * time.Second)
}

当然,这也只是names变量不会发生变化的情况下适用,最好我们直接将要使用的量复制出来。

func main() {
  var names = []string{"Alice", "Bob", "Charlie", "Dave", "Eve"}
  for i := range names {
    name:= names[i]
    go func() {
      fmt.Printf("Hello %s!\n", name)
    } ()
  }
  time.Sleep(1 * time.Second)
}

如果你的结构体比较复杂,你担心拷贝会影响性能,那么你只能通过其他办法保证协程执行完成前names不会发生变化,比如使用sync.WaitGroup。

六. 使用循环调用函数创建协程

在上面的例子中我们需要主动拷贝变量,避免协程在使用变量前变量发生变化带来的问题。那么为什么不直接通过参数传递呢?

func greet(name string) {
  fmt.Printf("Hello %s!\n", name)

func main() {
  var names = []string{"Alice", "Bob", "Charlie", "Dave", "Eve"}
  for i := range names {
    go greet(names[i])
  }
  time.Sleep(1 * time.Second)
}

如果你的函数需要复用,正好可以使用这种方式。

七. 使用Channel获取返回值

如果协程不需要返回结果,比如我们上面的例子,那么直接传递参数给被调函数就好了。如果我们的任务不是打印,而是需要将结果返回给主调函数,那么就需要借助管道。我们经常编写的web服务器都是这种需求。

func greet(name string, ch chan string) {
  ch <- fmt.Sprintf("<div>Hello %s!</div>", name)
}
func main() {
  var names = []string{"Alice", "Bob", "Charlie", "Dave", "Eve"}
  results := make(chan string, 5)
  for i := range names {
    go greet(names[i], results)
  }
  time.Sleep(1 * time.Second)
  close(results)
  for greeting := range results {
    fmt.Println(greeting)
  }
}

上面的程序中,我们使用time.Sleep等待协程完成是非常粗糙低效的方法,在编写真正的程序时千万不要这么做,最好还是使用WaitGroup。

func greet(name string, ch chan string, wg  *sync.WaitGroup) {
  defer wg.Done()
  ch <- fmt.Sprintf("<div>Hello %s!</div>", name)
}
func main() {
  var names = []string{"Alice", "Bob", "Charlie", "Dave", "Eve"}
  results := make(chan string, 5)
  wg := sync.WaitGroup{}
  wg.Add(len(names))
  for i := range names {
    go greet(names[i], results, &wg)
  }
  wg.Wait()
  close(results)
  for greeting := range results {
    fmt.Println(greeting)
  }
}

你有没有觉得将wg通过传参的方式传到greet函数内部非常的不优雅?这样限制了greet函数的复用性,所以我们应该把WaitGroup从调用函数的过程中拆分出来。

func greet(name string, ch chan string) {
  ch <- fmt.Sprintf("<div>Hello %s!</div>", name)
}
func main() {
  var names = []string{"Alice", "Bob", "Charlie", "Dave", "Eve"}
  results := make(chan string, 5)
  wg := sync.WaitGroup{}
  wg.Add(len(names))
  for i := range names {
    name := names[i]
    go func() {
      defer wg.Done()
      greet(name, results)
    }()
  }
  wg.Wait()
  close(results)
  for greeting := range results {
    fmt.Println(greeting)
  }
}

上面的例子中,我们其实是使用匿名函数创建的协程,然后在协程中调用greet函数,这样就可以将协程控制和业务逻辑分开,协程控制在匿名函数中,业务逻辑在被调函数中。

既然可以将协程控制和业务逻辑分开来提高被调函数的复用性,那么也可以将Channel和被调函数分开来提高函数复用性。这样greeting就是一个普通的函数了,即使在不需要Channel和协程的情况下也可以复用。

func greet(name string) (greeting string) {
   return fmt.Sprintf("<div>Hello %s!</div>", name)
}
func main() {
  var names = []string{"Alice", "Bob", "Charlie", "Dave", "Eve"}
  results := make(chan string, 5)
  wg := sync.WaitGroup{}
  wg.Add(len(names))
  for i := range names {
    name := names[i]
    go func() {
      defer wg.Done()
      greeting := greet(name)
      results <- greeting
    }()
  }
  wg.Wait()
  close(results)
  for greeting := range results {
    fmt.Println(greeting)
  }
}

八. 使用Channel传入参数

在上面的例子中,我们为每个需要处理的name创建了一个协程,因为需要处理的参数并不是很多,这样没有什么问题,但是如果有成千上万,甚至不断的有参数需要处理,为每次请求创建协程可能会浪费大量的时间在创建协程上,那么我们可以只创建固定数量的协程,然后将需要处理的参数传递给协程。

const routineNumber = 100

func greet(request chan string) () {
   for name := range request {
     fmt.Printf("<div>Hello %s!</div>\n", name)
  }
}

func main() {
  var names = []string{"Alice", "Bob", "Charlie", "Dave", "Eve"}
  request := make(chan string)
  wg := sync.WaitGroup{}

  wg.Add(routineNumber)
  for i := 0; i < routineNumber; i++ {
    go func() {
      defer wg.Done()
      greet(request)
    }()
  }

  for i := range names {
    request <- names[i]
  }
  close(request)
  wg.Wait()
}

上面的例子中我们用100个协程处理5个请求似乎有些浪费,但是当请求增加到几千几万个或者源源不断之后,这样做就是很常见的做法了。

九. 主动结束协程

值得注意的是,在上面一个例子中,协程中的for循环时因为close(request)的操作而跳出的。也就是说,我们可以通过关闭管道来停止协程,但是如果协程没有传入参数,在实际应用中有很多这样的情况,比如协程接受http请求,处理并返回,或者监听消息队列,处理接受到的信息并记入数据库等, 我们应该怎么关闭循环的协程呢?我们可以创建一个无意义的管道来控制,也可以使用Context。

使用管道的方法和上文的例子类似,只不过使用的是一个空结构体管道。

func greet(stop chan struct{}) {
  for {
    select {
    case <-stop:
      break
    default: 
      time.Sleep(1 * time.Second)
      fmt.Println("Hello World!")
    }
  }
}

func main() {
  stop := make(chan struct{})
  go greet(stop)
  time.Sleep(10 * time.Second) 
  close(stop)
  time.Sleep(10 * time.Second)
}

上面的程序会每秒打印一个Hello World! 当运行大约十秒后停止打印,再过十秒后程序停止。select 关键字会随机执行一个case 后的命令,如果成功就会执行case下面的程序,这段程序中只有一个case,当执行<- stop时,因为stop管道中没有内容,就会阻塞,不能执行,所以在stop管道开着的过程中只能执行default中的程序。当管道关闭时,<- stop 就不会阻塞了,进入case下面的程序,随即跳出循环,协程就会退出。

上面的例子中我们使用管道控制协程是否退出,那么反过来想,其实我们也可以使用管道控制主进程是否退出,也就是在协程中关闭管道,并退出协程,当主进程发现管道关闭之后就知道协程已经退出了,然后就可以取消阻塞继续执行了。

context 包是更高级的解决方案,context包定义了Context接口,Context定义了4个函数,其中Done() 返回的是一个空结构体管道。可以用来控制协程退出。

func greet(ctx context.Context) {
  for {
    select {
    case <- ctx.Done():
      break
    default: 
      time.Sleep(1 * time.Second)
      fmt.Println("Hello World!")
    }
  }
}

func main() {
  ctx, cancel := context.WithCancel(context.Background())
  go greet(ctx)
  time.Sleep(10 * time.Second) 
  cancel()
  time.Sleep(10 * time.Second)
}

当然Context的功能远不止于此,它还可以用来控制超时和保存数据等。

十. 创建多协程任务

从上面的各种例子中我们可以看到,本来被调函数非常简单,但是为了使用协程提高并发性能,我们写了很多的代码来创建协程。该不会是每次需要创建协程都要写这么多代码吧。当然不是,程序员最大的优点就是会把重复的东西复用。

func greet(name string){
   fmt.Printf("<div>Hello %s!</div>\n", name)
}

func multiExec(f func(string), names []string) {
  wg := sync.WaitGroup{}
  wg.Add(len(names))
  for i := range names {
    name := names[i]
    go func() {
      defer wg.Done()
      f(name)
    }()
  }
  wg.Wait()
}

func main() {
  var names = []string{"Alice", "Bob", "Charlie", "Dave", "Eve"}
  multiExec(greet, names)
}

通过上面的multiExec函数的封装,我们每次需要使用多协程打印names的时候都只需要一条语句就可以了。

但是这样复用性依然很差,因为multiExec 只接受func(string)类型的函数。如果想要用这个函数创建多个协程就很麻烦了。所以我们创建协程时应该传入无差别的函数。你还记得我们前面用匿名函数调用函数创建协程吗?

func greet(name string){
   fmt.Printf("<div>Hello %s!</div>\n", name)
}

func multiExec(f func(), count int) (wg *sync.WaitGroup) {
  wg = &sync.WaitGroup{}
  wg.Add(count)
  for i:=0; i < count; i++ {
    go func() {
      defer wg.Done()
      f()
    }()
  }
  return wg
}

func main() {
  count := 5
  var names = []string{"Alice", "Bob", "Charlie", "Dave", "Eve"}
  nameCh := make(chan string, 5)

  wg := multiExec(func (){
      for name := range nameCh{
        greet(name)
      }
    }, count)

  for i := range names {
    nameCh <- names[i]
  }
  close(nameCh)

  wg.Wait()
}

这个例子看起来似乎并没有省多少代码,这是因为比较的对象不同,如果你的程序非常复杂,通过这种形式的封装还是可以减少不少工作量的。或者,你也可以直接将Channel或者Context等工具封装到multiExec函数中来处理更复杂的任务。

这个例子看起来似乎并没有省多少代码,这是因为比较的对象不同,如果你的程序非常复杂,通过这种形式的封装还是可以减少不少工作量的。或者,你也可以直接将Channel或者Context等工具封装到multiExec函数中来处理更复杂的任务。

十一. 通过传递函数创建协程

除了通过管道传递参数,我们也可以用管道传递函数,直接把参数通过闭包的形式封装在函数中传递协程。

func multiExec(count, len int) (tasks chan func(), wg *sync.WaitGroup) {
    wg = &sync.WaitGroup{}
    tasks = make(chan func(), len)
    // 创建任务队列
    for i := 0; i < count; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for t := range tasks {
                t()
            }
        }()
    }
    return
}

func main() {
    var names = []string{"Alice", "Bob", "Charlie", "Dave", "Eve"}

    taskChan, wg := multiExec(4, 1)
    for i := range names {
        name := names[i]
        taskChan <- func() {
            fmt.Println(name)
        }
    }
    close(taskChan)
    wg.Wait()
}

并且这样我们可以不断的向队列中添加各种各样的任务,而不仅仅局限于一开始设定的函数。

UEFI是什么,有什么用?如何使用UEFI方式安装系统?

UEFI直白的讲是一种系统启动方式,但是这样讲实际上是不严格的。UEFI的全称是统一可扩展固件接口(Unified Extensible Firmware Interface),这就表示实际上它是一系列统一的接口标准,但是我们通常更关注它对系统引导的影响,我们通常拿它和BIOS方式做对比,本片文章也主要讨论UEFI对于启动的影响。

我们在配置启动选项的时候通常看到的是UEFI和Legacy两种方式,Legacy也就是传统BIOS对应的启动方式。说是启动方式,实际上说成“发现启动的方式”更加好理解。因为在启动过程中UEFI和Legacy主要功能是发现磁盘上可以启动的选项。

现在我们就来看看Legacy方式和UEFI方式分别是怎么发现启动的。在早期,系统的启动项是写在主引导记录也就是MBR中的,这个记录只能写在磁盘开头的446字节中,很明显主引导记录的容量和位置都有很大的局限性,并且磁盘的分区信息也是写在主引导记录中的,主引导记录的容量限制就更加明显了。也正是因为主引导记录的体积限制,我们只能用它创建4个分区的描述,这也是广为流传的一个硬盘只能创建4个主分区的原因。因为主引导记录的特点,电脑在开机的时候只需要完成一定工作后读取主引导记录并按照主引导记录的描述继续读取需要运行的程序就可以了,但是如果主引导记录损坏,那整个磁盘就都会遇到故障,这也是使用Legacy经常遇到系统无法启动的原因。

而对应UEFI的引导方式不再继续使用MBR分区方式,而是采用GPT分区表。GPT分区表可以带来更多的分区和更大的分区容量。虽然很多人用不上更多的分区和更大的磁盘,但我依然推荐使用GPT分区方式。实际上GPT分区表里是有MBR的,但是包含MBR记录的目的仅仅是告诉那些只支持MBR的电脑不要乱动我的分区表。在使用GPT分区表的情况下,机器不能仅仅通过读取第一个扇区就确定可以从哪里启动了,但是不用担心因为UEFI比BIOS更加强大,它不仅能轻松发现哪些分区可以启动,甚至可以直接挂载分区进行文件管理。实际上UEFI发现启动选项正式依靠文件管理,UEFI可以直接读取各个分区的文件搜索可以启动的文件,如果你的机器是使用UEFI方式引导的,你通常可以在启动分区中发现一个叫做EFI的文件夹,这里面就是引导要用到的程序。如果你确定你的电脑是UEFI方式引导的,但是却看不到EFI文件,那是因为有一个专门的EFI分区,并且这个分区通常是没有设置卷标或者挂载出来。使用这个方式发现引导的好处很明显,首先避免的主引导记录的位置和容量的限制,所有分区的任意位置都可以存储引导信息和引导程序,其次,如果你的硬盘头部的扇区损坏了,你完全可以不用他们,只需要在分区的时候跳过它们,不管你的分区在哪个位置,都可以通过写入EFI文件配置启动。其次得益于UEFI的文件管理功能,有些高级的主板甚至支持直接挂载分区对分区中的文件进行编辑,这样在发生引导错误的时候我们就可以直接通过覆写文件的方式进行修复,实际上Windows10上的自动修复修复功能就是这样实现的(但是确实更加复杂),相比传统的编辑磁盘头部的主引导记录,这种方式真避免了很多麻烦。

那么如何设置UEFI启动呢?配置UEFI启动需要在安装系统的时候配置。首先你要确定你的机器支持UEFI模式启动,只要你的机器不是古董机通常都可以使用UEFI方式启动,你可以在BIOS(你注意到我这里使用BIOS指代UEFI和传统BIOS,实际上UEFI就是一种BIOS)选项中查看引导选项,通常有Legacy、UEFI和兼容3种模式,如果你确定你不在需要使用Legacy启动方式,你也可以直接设置为仅UEFI模式。

然后需要确定你要安装的操作系统是否支持UEFI模式启动。方法很简单,使用虚拟磁盘挂载安装镜像或者使用解压缩软件打开镜像,查看镜像中有没有EFI文件夹。如果存在EFI文件夹,那就说明这个镜像支持以UEFI的方式启动。

下面我们就来创建一个可以使用UEFI模式启动的U盘,使用Legacy方式启动的启动U盘通常需要使用烧录软件模拟成CD-ROM进行创建,相比之下,创建UEFI模式启动的U盘就简单多了。首先将U盘格式化为FAT32格式,如果你的U盘已经是FAT32格式就不需要再进行格式化了,然后使用虚拟光驱挂载镜像,将所有文件拷贝的U盘中,或者直接使用解压缩工具解压到U盘中。得益于UEFI模式使用文件管理发现启动项的方式,只要使用主板上的UEFI程序支持的文件系统并将所有安装文件拷贝到分区中就可以创建一个引导分区。同理,使用任何主板支持的存储介质都可以创建一个安装盘。

值得之前没有使用UEFI安装系统的同学注意,在使用UEFI安装系统的时候要选择有UEFI前缀的引导项,如果你找不到这个引导项,需要到BIOS中确认是否支持或者打开UEFI引导方式。

希望通过上文,你可以明白UEFI并没有多么复杂,相比之下使用UEFI来配置计算机更加简单,并且可以带来更多的好处。甚至有些厂商会在UEFI中嵌入反病毒程序,这也都得益于UEFI文件管理等新特点。至于BIOS和UEFI在运行中的区别,我们可以理解成传统BIOS只知道读取磁盘的第一条记录,然后按照他的描述去启动,而UEFI不一样,UEFI清楚的知道自己启动的是什么以及如何启动它,甚至还可以对启动项目安排一下,总之更高级就是了。

Golang 错误声明的几种方式

golang的错误处理机制非常特别,golang将错误声明为一种接口,并且可以作为参数传递。这种机制是为了让程序员重视错误处理,同时避免胡乱引发异常的情况。

在使用golang返回错误的时候,我通常使用以下几种方式:

1.直接返回声明的错误:

示例:

return errors.New("这是一个错误!")

return fmt.Errorf("这是一个错误!")

这种方式使用起来是最快捷的,同时也是我最不推荐的。因为这种方式上级函数拿到错误之后很难分析到底发生了什么错误,即使可以通过比对字符串进行分析,也是既低效又不稳定的方法。

2.返回声明的错误变量

示例:

var ErrMyErr  error = errors.New("这是一个错误!")

fun returnErr() err {
    return ErrMyErr
}

这种方式是我最推荐的方式,因为这是声明最方便,同时也是判断最简单的方案。上级函数拿到错误之后只需要简单的使用 == 运算就可以判断错误类型。

if err == ErrMyErr {
    // 进行错误处理
}

3.声明一种错误结构体:

示例:

type ErrMyErr struct {
    ErrString string
}

func (e ErrMyErr) Error() string {
    return fmt.Sprintf("字符串%s发生了错误", e.ErrString)
}

// 返回错误的函数
func returnErr()  error {
    return ErrMyErr{"发生错误的字符串"}
}

如果你的错误比较简单,只需要返回一个错误变量就可以让上级函数轻松的判断具体发生了什么错误,但是如果你的错误比较复杂,有一些参数需要跟随错误上报,那么简单的变量就不能满足这种情况了,我们需要先声明一种错误结构体,表示一类错误,然后声明独特的错误实例。这种情况适用于这一类错误每次发生的情况都会有一些小的区别的时候,比如查询数据库中的行的时候,某一列不存在,需要通过返回错误告知。

这种情况下判断错误类型也很简单:

if _, ok := err.(ErrMyErr); ok {
    // 错误处理
}

4.声明一种错误结构体,同时通过属性来区别错误。

示例:

type ErrMyErr struct {
    ErrString string
    ErrType uint
}

func (e ErrMyErr) Error() string {
    return fmt.Sprintf("字符串%s发生了错误", e.ErrString)
}

const (
    ErrTypezero    uint =  iota
    ErrTypeone
    //....
)

// 返回错误的函数
func returnErr() error {
    return ErrMyErr{"发生错误的字符串", ErrTypezero}
}

这种返回错误的方式就比较复杂了,幸亏它的适用场景也很少,大多数情况下,最好不要使用这种返回错误的方式。这种错误的定义方式适用于那些存在很多种类的错误,同时又是一大类错误的时候,有的时候我们只需要知道发生的错误是不是这一个大类,有的时候我们又希望知道具体发生的错误是哪个小类。当然我们也可以继续使用上一种错误声明方式,但是可能又要面临第一中错误声明方式遇到的比对字符串的情况。所以不如直接在结构体中定义一个属性用于说明错误的小类。

判断方法:

if _, ok := err.(ErrMyErr); ok {
    // 错误处理
}
if e, ok := err.(ErrMyErr); ok && e.ErrType == ErrTypezero {
    // 错误处理
}

5.使用普通的变量代表错误

以上几种方式都是围绕golang的错误处理机制实现的,但是其实仔细一想,这种错误处理机制并不受限于golang的语法,只是一种大家共同认可的方式,如果你自己简单的声明一个变量代表一种错误,同样可以实现返回错误。但是为了代码的兼容性,我也完全不赞成在错误处理的时候使用这种方式,除非你的错误非常小,或者算不上什么错误,别人根本不会在乎,也完全不需要实现Error()函数。

示例:

func returnErr() int {
    return 0
}

这几种方式是我能想到的几种声明错误的方式,我推荐先在包中定义错误变量,然后直接返回错误变量,这种方式几乎适用于绝大多数的情况,而且处理起来非常方便稳定。为了代码的兼容性和可维护性,希望看到这篇文章的人也都能养成良好的错误声明的习惯。

6.最近我在阅读别人的代码时发现了另外一种定义错误的方式,就是把字符串重定义为错误类型,然后直接将错误描述的字符串强制类型转换为错误类型返回。

type Err String
func (e Err) Error() string{return string(e)}
func returnErr() error {
    return Err{"这里发生了一个错误"}
}

你也许会觉得,这和第一种方式没有什么区别,但是实际上相比第一种方式,这种方式多了一个好处,在使用时可以判断这个错误是否你调用的模块发生的错误。通常,我们调用的模块都会有依赖模块,比如我们用的orm会调用数据库的模块,当数据库的模块发生错误的时候orm通常会直接把错误传递给我们,如果ORMorm块在定义错误的时候采用的是这种方式,那么我们就可以判断得到的错误是不是orm.Err类型,以了解到这个错误到底是在orm模块发生的还是在数据库模块发生的,而且返回错误的过程更加简单。