标签 goroutine 下的文章

探索Go守护进程的实现方法

本文永久链接 – https://tonybai.com/2024/10/03/how-to-daemonize-go-program

在后端开发的世界里,守护进程(daemon)这个概念与Unix系统一样古老。守护进程是在后台运行的长期服务程序,不与任何终端关联。尽管现代进程管理工具如systemdsupervisor等让应用转化为守护进程变得十分简单,我们甚至可以使用以下命令来在后台运行程序:

nohup ./your_go_program &

但在某些情况下,程序的原生转化为守护进程的能力仍然是有必要的。比如分布式文件系统juicefs cli的mount子命令,它就支持以-d选项启动,并以守护进程方式运行:

$juicefs mount -h
NAME:
   juicefs mount - Mount a volume

USAGE:
   juicefs mount [command options] META-URL MOUNTPOINT

... ...

OPTIONS:
   -d, --background  run in background (default: false)
   ... ...
... ...

这种自我守护化的能力会让很多Go程序受益,在这一篇文章中,我们就来探索一下Go应用转化为守护进程的实现方法。

1. 标准的守护进程转化方法

W.Richard Stevens的经典著作《UNIX环境高级编程》中对将程序转化为一个守护进程的 (daemonize) 步骤进行了详细的说明,主要步骤如下:

  • 创建子进程并终止父进程

通过fork()系统调用创建子进程,父进程立即终止,保证子进程不是控制终端的会话组首领。

  • 创建新的会话

子进程调用setsid()来创建一个新会话,成为会话组首领,从而摆脱控制终端和进程组。

  • 更改工作目录

使用chdir(“/”) 将当前工作目录更改为根目录,避免守护进程持有任何工作目录的引用,防止对文件系统卸载的阻止。

  • 重设文件权限掩码

通过umask(0) 清除文件权限掩码,使得守护进程可以自由设置文件权限。

  • 关闭文件描述符

关闭继承自父进程的已经open的文件描述符(通常是标准输入、标准输出和标准错误)。

  • 重定向标准输入/输出/错误

重新打开标准输入、输出和错误,重定向到/dev/null,以避免守护进程无意输出内容到不应有的地方。

注:fork()系统调用是一个较为难理解的调用,它用于在UNIX/Linux系统中创建一个新的进程。新创建的进程被称为子进程,它是由调用fork()的进程(即父进程)复制出来的。子进程与父进程拥有相同的代码段、数据段、堆和栈,但它们是各自独立的进程,有不同的进程ID (PID)。在父进程中,fork()返回子进程的PID(正整数),在子进程中,fork()返回0,如果fork()调用失败(例如系统资源不足),则返回-1,并设置errno以指示错误原因。

下面是一个符合UNIX标准的守护进程转化函数的C语言实现,参考了《UNIX环境高级编程》中的经典步骤:

// daemonize/c/daemon.c

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <syslog.h>
#include <signal.h>

void daemonize()
{
    pid_t pid;

    // 1. Fork off the parent process
    pid = fork();
    if (pid < 0) {
        exit(EXIT_FAILURE);
    }
    // If we got a good PID, then we can exit the parent process.
    if (pid > 0) {
        exit(EXIT_SUCCESS);
    }

    // 2. Create a new session to become session leader to lose controlling TTY
    if (setsid() < 0) {
        exit(EXIT_FAILURE);
    }

    // 3. Fork again to ensure the process won't allocate controlling TTY in future
    pid = fork();
    if (pid < 0) {
        exit(EXIT_FAILURE);
    }
    if (pid > 0) {
        exit(EXIT_SUCCESS);
    }

    // 4. Change the current working directory to root.
    if (chdir("/") < 0) {
        exit(EXIT_FAILURE);
    }

    // 5. Set the file mode creation mask to 0.
    umask(0);

    // 6. Close all open file descriptors.
    for (int x = sysconf(_SC_OPEN_MAX); x>=0; x--) {
        close(x);
    }

    // 7. Reopen stdin, stdout, stderr to /dev/null
    open("/dev/null", O_RDWR); // stdin
    dup(0);                    // stdout
    dup(0);                    // stderr

    // Optional: Log the daemon starting
    openlog("daemonized_process", LOG_PID, LOG_DAEMON);
    syslog(LOG_NOTICE, "Daemon started.");
    closelog();
}

int main() {
    daemonize();

    // Daemon process main loop
    while (1) {
        // Perform some background task...
        sleep(30); // Sleep for 30 seconds.
    }

    return EXIT_SUCCESS;
}

注:这里省略了书中设置系统信号handler的步骤。

这里的daemonize函数完成了标准的守护化转化过程,并确保了程序在后台无依赖地稳定运行。我们编译运行该程序后,程序进入后台运行,通过ps命令可以查看到类似下面内容:

$ ./c-daemon-app
$ ps -ef|grep c-daemon-app
root     28517     1  0 14:11 ?        00:00:00 ./c-daemon-app

我们看到c-daemon-app的父进程是ppid为1的进程,即linux的init进程。我们看到上面c代码中转化为守护进程的函数daemonize进行了两次fork,至于为何要做两次fork,在我的《理解Zombie和Daemon Process》一文中有说明,这里就不赘述了。

那么Go是否可以参考上述步骤实现Go程序的守护进程转化呢?我们接着往下看。

2. Go语言实现守护进程的挑战

关于Go如何实现守护进程的转换,在Go尚未发布1.0之前的2009年就有issue提到,在runtime: support for daemonize中,Go社区与Go语言的早起元老们讨论了在Go中实现原生守护进程的复杂性,主要挑战源于Go的运行时及其线程管理方式。当一个进程执行fork操作时,只有主线程被复制到子进程中,如果fork前Go程序有多个线程(及多个goroutine)在执行(可能是由于go runtime调度goroutine和gc产生的线程),那么fork后,这些非执行fork线程的线程(以及goroutine)将不会被复制到新的子进程中,这可能会导致后续子进程中线程运行的不确定性(基于一些fork前线程留下的数据状态)。

理想情况下是Go runtime提供类似的daemonize函数,然后在多线程启动之前实现守护进程的转化,不过Go团队至今也没有提供该机制,而是建议大家使用如systemd的第三方工具来实现Go程序的守护进程转化。

既然Go官方不提供方案,Go社区就会另辟蹊径,接下来,我们看看目前Go社区的守护进程解决方案。

3. Go社区的守护进程解决方案

尽管面临挑战,Go社区还是开发了一些库来支持Go守护进程的实现,其中一个star比较多的解决方案是github.com/sevlyar/go-daemon。

go-daemon库的作者巧妙地解决了Go语言中无法直接使用fork系统调用的问题。go-daemon采用了一个简单而有效的技巧来模拟fork的行为:该库定义了一个特殊的环境变量作为标记。程序运行时,首先检查这个环境变量是否存在。如果环境变量不存在,执行父进程相关操作,然后使用os.StartProcess(本质是fork-and-exec)启动带有特定环境变量标记的程序副本。如果环境变量存在,执行子进程相关操作,继续执行主程序逻辑,下面是该库作者提供的原理图:

这种方法有效地模拟了fork的行为,同时避免了Go运行时中与线程和goroutine相关的问题。下面是使用go-daemon包实现Go守护进程的示例:

// daemonize/go-daemon/main.go

package main

import (
    "log"
    "time"

    "github.com/sevlyar/go-daemon"
)

func main() {
    cntxt := &daemon.Context{
        PidFileName: "example.pid",
        PidFilePerm: 0644,
        LogFileName: "example.log",
        LogFilePerm: 0640,
        WorkDir:     "./",
        Umask:       027,
    }

    d, err := cntxt.Reborn()
    if err != nil {
        log.Fatal("无法运行:", err)
    }
    if d != nil {
        return
    }
    defer cntxt.Release()

    log.Print("守护进程已启动")

    // 守护进程逻辑
    for {
        // ... 执行任务 ...
        time.Sleep(time.Second * 30)
    }
}

运行该程序后,通过ps可以查看到对应的守护进程:

$make
go build -o go-daemon-app
$./go-daemon-app 

$ps -ef|grep go-daemon-app
  501  4025     1   0  9:20下午 ??         0:00.01 ./go-daemon-app

此外,该程序会在当前目录下生成example.pid(用于实现file lock),用于防止意外重复执行同一个go-daemon-app:

$./go-daemon-app
2024/09/26 21:21:28 无法运行:daemon: Resource temporarily unavailable

虽然原生守护进程化提供了精细的控制且无需安装和配置外部依赖,但进程管理工具提供了额外的功能,如开机自启、异常退出后的自动重启和日志记录等,并且Go团队推荐使用进程管理工具来实现Go守护进程。进程管理工具的缺点在于需要额外的配置(比如systemd)或安装设置(比如supervisor)。

4. 小结

在Go中实现守护进程化,虽然因为语言运行时的特性而具有挑战性,但通过社区开发的库和谨慎的实现是可以实现的。随着Go语言的不断发展,我们可能会看到更多对进程管理功能的原生支持。同时,开发者可以根据具体需求,在原生守护进程化、进程管理工具或混合方法之间做出选择。

本文涉及的源码可以在这里下载。


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Go语言中的深拷贝:概念、实现与局限

本文永久链接 – https://tonybai.com/2024/09/28/understand-deep-copy-in-go

前不久,在“Gopher部落”知识星球上回答了一个Gopher关于深拷贝(Deep Copy)的问题,让我感觉是时候探讨一下深拷贝技术了。

在日常开发工作中,深拷贝的使用频率相对较低,可能有80%的时间不需要使用深拷贝,只有在特定情况下才会遇到。这主要是因为大多数开发中处理的对象比较简单,通常只需使用浅拷贝(Shallow Copy)就能满足需求;此外,多数时候我们需要共享状态或数据,使用浅拷贝可以方便多个部分访问同一数据;最后,深拷贝通常比浅拷贝耗时更多,尤其是当对象嵌套较深时。因此,开发者倾向于选择更高效的浅拷贝。

说了这么多,那究竟什么是深拷贝以及浅拷贝呢?深拷贝又是在哪些场合下适用呢?在Go中如何实现深拷贝呢?带着这些问题,我们在本文中就来探讨一下Go语言中的深拷贝技术,希望能让大家对深拷贝技术的概念、实现以及局限有一个全面的了解。

1. 从细胞分裂看深拷贝

我们在初中生物课上都学过细胞分裂(Cell Division),有条件的学校的学生可以用显微镜观看到细胞分裂的全过程,大致就如下图所示:


细胞分裂过程(图片来自网络)

我们知道细胞分裂复制了整个细胞的所有成分,包括细胞核、细胞质等,生成了一个完全独立的新细胞。无论原始细胞如何变化,分裂出的新细胞不会受到影响。而深拷贝就像是真正的细胞分裂,完全复制了原对象及其内部所有嵌套对象的数据,使新对象和原对象相互完全独立,各自演进,互不影响。

下面,我将使用Go语言给出一个结构体类型的示例,并用示意图直观展示深拷贝和浅拷贝的区别:

// Address 结构体
type Address struct {
    City  string
    State string
}

// Person 结构体
type Person struct {
    Name    string
    Age     int
    Address *Address
}

这里定义了Address和Person两个结构体,其中Person包含一个指向Address的指针(这可以理解为Person结构体的嵌套对象)。我们先来创建一个原始对象:

// 创建原始 Person 实例
original := Person{
    Name: "Alice",
    Age:  30,
    Address: &Address{
        City:  "New York",
        State: "NY",
    },
}

基于这个原始对象,我们可以使用下面代码创建一个浅拷贝的对象:

shallowCopy := original

下面是浅拷贝完毕的对象关系示意图:

我们看到浅拷贝后,两个Person对象虽然有部分字段已经完全独立分开(Name和Age),但仍然存在关联,那就是Address字段指向了同一个Address对象。这样无论是原始对象修改了Address,还是浅拷贝后的对象修改了Address,都会对另一个对象产生影响。

我们再来看看深拷贝,这里为Person结构体增加了深拷贝的方法,然后通过该方法得到一个深拷贝后的对象:

// DeepCopy方法
func (p Person) DeepCopy() Person {
    newPerson := p
    if p.Address != nil {
        newAddress := *p.Address
        newPerson.Address = &newAddress
    }
    return newPerson
}

deepCopy := original.DeepCopy()

我们看到:DeepCopy方法实现了对Person的深拷贝,它不仅复制了Person结构体,还创建了一个新的Address结构体并复制了其内容。这样原始对象与深拷贝出的对象就完全分开了,下面是深拷贝后的对象关系示意图:

通过上面的示意图,我们可以将深拷贝与浅拷贝的对比整理如下:

  • 浅拷贝(Shallow Copy)

创建一个新对象,并复制原对象的字段值,但对于引用类型(如指针、切片、map等),仅复制引用,不复制引用的对象。通常通过简单的赋值操作就能实现浅拷贝。

  • 深拷贝(Deep Copy)

创建一个新对象,递归地复制原对象的所有字段值,对于引用类型,创建新的对象并复制其内容,而不是简单地复制引用。通常,深拷贝需要额外编写代码实现,简单的赋值操作对于复杂类型而言,无法实现深拷贝。

很显然就像在本文开始时所说的那样,我们日常使用最多的就是浅拷贝,浅拷贝的实现也是非常简单的,通过赋值语句就可以。那么我们为什么还需要深拷贝呢?或者说,在什么场景下需要使用到深拷贝呢?下面我就就来看看。

2. 为什么需要深拷贝?

根据上面提到的深拷贝的特点:独立与隔离,当数据的独立性和隔离性非常重要时,它能避免共享数据引发的副作用。据此,以下是需要使用深拷贝的常见场景,我们逐一简要说明一下。

2.1 防止意外修改共享数据

在Go语言中,切片、map和指针都是引用类型。如果多个对象引用同一个底层数据结构,修改其中一个对象的数据会影响所有引用该数据的对象。因此,在这些场合下,如果希望避免修改一个对象时影响其他对象,使用深拷贝是必需的。

下面这个Go例子中,shallowCopy和original共享同一个Data map,修改shallowCopy的数据会直接影响original。通过深拷贝Data map,deepCopy保持了数据的独立性:

package main

import "fmt"

type Config struct {
    Port int
    Data map[string]string
}

func main() {
    original := &Config{
        Port: 8080,
        Data: map[string]string{"key1": "value1"},
    }

    shallowCopy := original // 只是浅拷贝,共享Data引用

    // 深拷贝 Data
    deepCopy := &Config{
        Port: original.Port,
        Data: make(map[string]string),
    }
    for k, v := range original.Data {
        deepCopy.Data[k] = v
    }

    shallowCopy.Data["key1"] = "modified" // 修改会影响original
    fmt.Println(original.Data["key1"])    // 输出 "modified"

    deepCopy.Data["key1"] = "deepModified" // 修改不会影响original
    fmt.Println(original.Data["key1"])     // 输出 "modified"
}

2.2 并发编程中的数据隔离

Go语言利用goroutine进行并发编程。当多个goroutine操作相同的数据时,可能会导致竞争条件和数据一致性问题。如果每个goroutine都需要独立的数据副本,那么深拷贝是确保数据隔离的最佳方法。

下面这个示例就是在并发场景下,使用append深拷贝切片,确保每个goroutine操作的是独立的data副本,避免数据竞争:

package main

import "fmt"

func worker(data []int, ch chan []int) {
    // 深拷贝切片,避免影响其他 goroutine
    newData := append([]int(nil), data...)
    for i := range newData {
        newData[i] *= 2 // 修改数据
    }
    ch <- newData
}

func main() {
    data := []int{1, 2, 3}
    ch := make(chan []int)

    go worker(data, ch) // 启动goroutine
    go worker(data, ch) // 启动另一个goroutine

    result1 := <-ch
    result2 := <-ch

    fmt.Println(result1) // goroutine 1的独立数据副本 [2 4 6]
    fmt.Println(result2) // goroutine 2的独立数据副本 [2 4 6]
}

2.3 不可变对象需求

Go目前不直接支持不可变对象,但在某些场合(如函数式编程或安全性要求较高的应用),不可变性是很有用的。如果你希望传递给某个函数的数据不能被修改,那么需要在传递前对数据进行深拷贝。

下面示例通过深拷贝,保证original的数据在传递过程中不会被修改,保证了不可变性:

package main

import "fmt"

type ImmutableData struct {
    Values []int
}

// 修改函数
func modifyData(data ImmutableData) {
    data.Values[0] = 100 // 尝试修改
}

func main() {
    original := ImmutableData{
        Values: []int{1, 2, 3},
    }

    // 传递之前进行深拷贝
    copyData := ImmutableData{
        Values: append([]int(nil), original.Values...),
    }

    modifyData(copyData)
    fmt.Println(original.Values) // 输出 [1 2 3],original数据保持不变
}

2.4 回滚机制或撤销操作

在涉及事务处理或编辑器等场景中,Go开发者常需要在操作前保存对象的快照,以便在出现错误或用户撤销操作时恢复到原状态。这时候,深拷贝用于保存独立的状态副本。下面示例使用了更复杂的数据结构来展示深拷贝的作用,并体现了在实际应用中如何通过深拷贝实现状态的回滚机制:

package main

import (
    "encoding/json"
    "fmt"
)

// State 结构体包含嵌套结构体和引用类型
type State struct {
    Value    string
    Data     []int
    Metadata *Metadata
}

// Metadata 是嵌套的引用类型结构体
type Metadata struct {
    Version int
    Author  string
}

// 深拷贝函数,通过JSON序列化与反序列化实现
func deepCopy(original *State) *State {
    copy := &State{}
    bytes, _ := json.Marshal(original)
    _ = json.Unmarshal(bytes, copy)
    return copy
}

func main() {
    // 初始化原始状态
    state := &State{
        Value: "initial",
        Data:  []int{1, 2, 3},
        Metadata: &Metadata{
            Version: 1,
            Author:  "Alice",
        },
    }

    // 保存当前状态的深拷贝
    backup := deepCopy(state)

    // 修改状态
    state.Value = "modified"
    state.Data[0] = 100
    state.Metadata.Version = 2

    // 输出修改后的状态
    fmt.Println("Current state:", state.Value)                       // 输出 "modified"
    fmt.Println("Current Data:", state.Data)                         // 输出 "[100 2 3]"
    fmt.Println("Current Metadata.Version:", state.Metadata.Version) // 输出 "2"

    // 恢复之前的状态
    state = backup

    // 输出恢复后的状态
    fmt.Println("Restored state:", state.Value)                       // 输出 "initial"
    fmt.Println("Restored Data:", state.Data)                         // 输出 "[1 2 3]"
    fmt.Println("Restored Metadata.Version:", state.Metadata.Version) // 输出 "1"
}

在这个场景中,backup是对state的深拷贝,确保可以在需要时恢复到原始状态。

在以上这些场景中,深拷贝虽然开销较大,但它确保了数据的独立性、隔离性以及安全性。当然,深拷贝适用的场景可能不止这些,这里也无法穷举所有场景。

知道了深拷贝的一些应用场景后,我们再来梳理一下如何在Go中实现深拷贝,其实在上面的示例中已经见过不少深拷贝的实现方法了。

3. Go语言中实现深拷贝的方法

在Go语言中,实现深拷贝有几种常见的方法,每种方法都有其优缺点和适用场景。让我们逐一探讨这些方法。

3.1 手动实现深拷贝

赋值操作通常无法实现复杂结构的深拷贝,因此最常见的深拷贝实现方法就是像上面示例中那样根据具体的类型手动实现深拷贝。手动实现深拷贝是最直接但也可能是最繁琐的方法,通常我们要为每种要深拷贝的类型单独编写深拷贝函数DeepCopy(Go没有像Java那样有object基类,因此也没有内置的clone方法去override)。

关于手动实现深拷贝DeepCopy方法的示例在前面我们已经见识过了,比如最开始的那个Person类型DeepCopy方法。

手动实现深拷贝的优点显而易见,那就是开发者可以完全控制拷贝的过程,并且性能通常较好,可以避免使用反射等有额外开销的机制来实现。

当然不足也很明显,那就是我们需要为每个要支持深拷贝的类型都维护一个单独的实现,并且对于带有复杂嵌套结构的类型,这个实现还会很冗长和复杂。

当是否可以有“万能”的深拷贝函数呢?我们继续往下看。

3.2 使用反射实现通用深拷贝

借助Go的reflect大法,我们可以实现一个通用的深拷贝函数,理论上,可以适用于各种类型。下面是一个示例实现(仅是示例,不要用在生产中):

package main

import (
    "fmt"
    "reflect"
)

// 深拷贝函数,使用 reflect 递归处理各种类型
func DeepCopy(src interface{}) interface{} {
    if src == nil {
        return nil
    }

    // 通过 reflect 获取值和类型
    value := reflect.ValueOf(src)
    typ := reflect.TypeOf(src)

    switch value.Kind() {
    case reflect.Ptr:
        // 对于指针,递归处理指针指向的值
        copyValue := reflect.New(value.Elem().Type())
        copyValue.Elem().Set(reflect.ValueOf(DeepCopy(value.Elem().Interface())))
        return copyValue.Interface()

    case reflect.Struct:
        // 对于结构体,递归处理每个字段
        copyValue := reflect.New(typ).Elem()
        for i := 0; i < value.NumField(); i++ {
            fieldValue := DeepCopy(value.Field(i).Interface())
            copyValue.Field(i).Set(reflect.ValueOf(fieldValue))
        }
        return copyValue.Interface()

    case reflect.Slice:
        // 对于切片,递归处理每个元素
        copyValue := reflect.MakeSlice(typ, value.Len(), value.Cap())
        for i := 0; i < value.Len(); i++ {
            copyValue.Index(i).Set(reflect.ValueOf(DeepCopy(value.Index(i).Interface())))
        }
        return copyValue.Interface()

    case reflect.Map:
        // 对于映射,递归处理每个键值对
        copyValue := reflect.MakeMap(typ)
        for _, key := range value.MapKeys() {
            copyValue.SetMapIndex(key, reflect.ValueOf(DeepCopy(value.MapIndex(key).Interface())))
        }
        return copyValue.Interface()

    default:
        // 其他类型(基本类型,数组等)直接返回原始值
        return src
    }
}

type Address struct {
    Street string
    City   string
}

type Person struct {
    Name    string
    Age     int
    Address *Address
}

func main() {
    // 初始化原始对象
    original := &Person{
        Name: "Alice",
        Age:  30,
        Address: &Address{
            Street: "123 Go St",
            City:   "Golang City",
        },
    }

    // 使用 reflect 实现的通用深拷贝
    copy := DeepCopy(original).(*Person)

    // 修改拷贝对象的值
    copy.Address.City = "New City"
    copy.Age = 31

    // 输出结果
    fmt.Println("Original Addr:", original.Address) // 输出 &{123 Go St Golang City}
    fmt.Println("Copy Addr:", copy.Address)         // 输出 &{123 Go St New City}
}

我们看到,在示例中,reflect包可以在运行时检查和操作Go的值。通过reflect.ValueOf(src)获取到值后,根据值的类型(指针、结构体、切片、map等)再递归进行深拷贝。如果遇到指针类型,DeepCopy将递归地拷贝指向的值,新的值通过reflect.New创建;对于结构体类型,它通过NumField()遍历字段,并递归地深拷贝该字段;对切片进行深拷贝时,首先使用reflect.MakeSlice()创建新的切片,再递归处理每个元素; 对于map,它用reflect.MakeMap()创建新的map,并递归处理键值对。

使用reflect包实现深拷贝的优点十分明显,那就是通用性强,能够处理各种数据结构(如指针、结构体、切片、map等),无需为每个类型单独实现DeepCopy方法。但由于使用了reflect,其带来的额外开销也是不可忽视的,尤其是对于嵌套很深的复杂类型。

有些情况是reflect无法正确处理的,比如被拷贝的类型中带有非导出字段时(比如给Person结构体增加一个gender字段),上面的反射版DeepCopy实现就会抛出panic:

panic: reflect.Value.Interface: cannot return value obtained from unexported field or method

此外,实现一个生产级的DeepCopy并非易事,我们可以找一些“久经考验”的第三方库,比如下面的jinzhu/copier。

3.3 使用第三方库

有一些第三方库提供了深拷贝功能,例如github.com/jinzhu/copier,这类库通常结合了反射和一些优化技巧。在经过广泛的使用和反馈后,可以在生产中使用,并且可以覆盖大多数需求场景。

下面是使用copier实现对带有非导出字段的结构体类型的深拷贝:

package main

import (
    "fmt"

    "github.com/jinzhu/copier"
)

type Person struct {
    Name    string
    Age     int
    Address *Address
    gender  string
}

type Address struct {
    Street string
    City   string
}

func main() {
    addr := Address{
        Street: "Go 101 street",
        City:   "Mars Capital",
    }
    original := Person{
        Name:    "Alice",
        Age:     30,
        Address: &addr,
        gender:  "female",
    }

    fmt.Println(original) // 输出:{Alice 30 0xc0000b0000 female}

    var copied Person
    err := copier.CopyWithOption(&copied, &original, copier.Option{
        DeepCopy: true,
    })
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(copied) // 输出:{Alice 30 0xc0000b0020 female}
}

copier是怎么做到的呢?翻看copier源码,可以找到这样一个函数:

func copyUnexportedStructFields(to, from reflect.Value) {
    if from.Kind() != reflect.Struct || to.Kind() != reflect.Struct || !from.Type().AssignableTo(to.Type()) {
        return
    }

    // create a shallow copy of 'to' to get all fields
    tmp := indirect(reflect.New(to.Type()))
    tmp.Set(from)

    // revert exported fields
    for i := 0; i < to.NumField(); i++ {
        if tmp.Field(i).CanSet() {
            tmp.Field(i).Set(to.Field(i))
        }
    }
    to.Set(tmp)
}

我们看到copyUnexportedStructFields函数首先检查源值和目标值是否都是结构体,并且源类型是否可以赋值给目标类型。如果可以赋值,则创建一个目标类型的新实例tmp,并将源值完整地设置到这个新实例中。这一步可以复制所有字段,包括非导出字段。接下来,遍历目标结构体的所有字段。对于可以设置的字段(即导出字段),将原始目标值中的对应字段值设置回tmp。最后,将tmp设置回原始目标值。

这个过程巧妙地利用了Go语言的反射机制。通过创建一个新的结构体实例并直接设置整个源值,它可以绕过Go语言对非导出字段的访问限制。然后,通过只恢复导出字段的原始值,保持了目标结构体中导出字段的完整性,同时保留了源结构体中非导出字段的值。

然而,这种方法也有一些潜在的限制,比如对于包含指针或引用类型的非导出字段,这种方法就无法真正实现深拷贝,我们改造一下上面的示例:

type Person struct {
    Name    string
    Age     int
    Address *Address
    gender  *string
}

type Address struct {
    Street string
    City   string
}

func (p *Person) SetGender(gender string) {
    p.gender = &gender
}
func (p *Person) Gender() *string {
    return p.gender
}

func main() {
    addr := Address{
        Street: "Go 101 street",
        City:   "Mars Capital",
    }
    original := Person{
        Name:    "Alice",
        Age:     30,
        Address: &addr,
    }
    original.SetGender("female")

    fmt.Println(original) // 输出:{Alice 30 0xc00006a020 0xc000014070}
    fmt.Println(original.Gender()) // 输出:0xc000014070

    var copied Person
    err := copier.CopyWithOption(&copied, &original, copier.Option{
        DeepCopy: true,
    })
    if err != nil {
        fmt.Println(err)
        return
    }
    fmt.Println(copied) // 输出:{Alice 30 0xc00006a040 0xc000014070}
    fmt.Println(copied.Gender()) // 输出:0xc000014070
}

这里我们在Person类型中增加了一个字符串指针类型的非导出字段gender,我们看到通过copier进行拷贝的结果并不符合深拷贝的要求,copied和original使用了同一个gender了。因此,像jinzhu/copier这样的第三方库,虽然能处理大多数常见情况,但我们仍要明确它的局限。

不过即便有了上述三类实现深拷贝的方法,有些时候要在Go中实现完美的深拷贝也是很难的,甚至是不可能的,下面我们来看看Go语言中深拷贝的局限性。

4. Go语言中深拷贝的局限性

我们先从已经遇到过的非导出字段说起。

4.1 无法访问的非导出字段

就像上面示例中那样,如果原类型中带有非导出字段,那么有些时候即便使用jinzhu/copier这样的第三方通用拷贝库也很难实现真正的深拷贝。如果原类型在你的控制下,最好的方法是为原类型手动添加一个DeepCopy方法供外部使用

不过,即便如此,某些情况下,手工实现一个DeepCopy方法也是很难的,甚至是不可能的,我们看下面两种局限的情况。

4.2 循环引用问题

当原类型中存在循环引用时,简单的递归深拷贝可能会导致无限循环。例如:

type Node struct {
    Value int
    Next  *Node
    Prev  *Node
}

func main() {
    node1 := &Node{Value: 1}
    node2 := &Node{Value: 2}
    node1.Next = node2
    node2.Prev = node1

    // 这里的深拷贝可能会导致无限递归
}

针对这样的带有循环引用的类型,我们通常会手工实现其DeepCopy方法,并通过使用类似哈希表的方式记录已经复制过的对象,下面是一个Node结构体的DeepCopy的示例实现:

package main

import (
    "fmt"
)

// Node表示双向链表的节点
type Node struct {
    Value int
    Next  *Node
    Prev  *Node
}

// DeepCopy方法:对Node进行深拷贝
func (n *Node) DeepCopy() *Node {
    // 初始化visited map用于记录已访问的节点,防止无限递归
    visited := make(map[*Node]*Node)
    return n.deepCopyRecursive(visited)
}

// deepCopyRecursive私有递归方法,内部处理深拷贝逻辑
func (n *Node) deepCopyRecursive(visited map[*Node]*Node) *Node {
    // 如果节点为空,返回nil
    if n == nil {
        return nil
    }

    // 如果节点已经被拷贝过,直接返回拷贝的引用
    if copyNode, found := visited[n]; found {
        return copyNode
    }

    // 创建当前节点的拷贝,并将其加入已访问map
    copyNode := &Node{Value: n.Value}
    visited[n] = copyNode

    // 递归拷贝下一个和前一个节点
    copyNode.Next = n.Next.deepCopyRecursive(visited)
    copyNode.Prev = n.Prev.deepCopyRecursive(visited)

    return copyNode
}

func main() {
    // 创建包含循环引用的双向链表
    node1 := &Node{Value: 1}
    node2 := &Node{Value: 2}
    node1.Next = node2
    node2.Prev = node1

    // 进行深拷贝
    copyNode1 := node1.DeepCopy()

    // 修改拷贝对象,确保原始对象不受影响
    copyNode1.Next.Value = 3

    // 输出原始链表和拷贝链表的指针地址,验证深拷贝是否成功
    fmt.Println("Original node1 address:", node1)
    fmt.Println("Original node1.Next address:", node1.Next)
    fmt.Println("Original node2.Prev address:", node2.Prev)

    fmt.Println("Copied node1 address:", copyNode1)
    fmt.Println("Copied node1.Next address:", copyNode1.Next)
    fmt.Println("Copied node2.Prev address:", copyNode1.Next.Prev)
}

运行这段示例程序会得到下面结果:

Original node1 address: &{1 0xc00011c018 <nil>}
Original node1.Next address: &{2 <nil> 0xc00011c000}
Original node2.Prev address: &{1 0xc00011c018 <nil>}
Copied node1 address: &{1 0xc00011c048 <nil>}
Copied node1.Next address: &{3 <nil> 0xc00011c030}
Copied node2.Prev address: &{1 0xc00011c048 <nil>}

下面再说一种极端情况,导致我们即便手工实现也无法实现深拷贝。

4.3 某些类型不支持拷贝

Go语言的某些内置类型或标准库中的类型,比如sync.Mutex、time.Timer等不应该被复制,复制这些类型可能会导致未定义的行为。

type Resource struct {
    Data  string
    mutex sync.Mutex
}

// 错误的深拷贝方式
func (r *Resource) DeepCopy() *Resource {
    return &Resource{
        Data:  r.Data,
        mutex: r.mutex, // 不应该复制 mutex
    }
}

对于这样的包含不支持拷贝的类型,我们在不改变源类型组成的情况下,无法实现深拷贝。

除了上面三种情况外,有些时候性能也是使用深拷贝时需要考量的点,尤其是当你使用反射实现的通用深拷贝技术时,可能会带来显著的性能开销。尤其是在关键路径上处理大型数据结构或频繁操作时,这可能成为一个问题。

如果在使用深拷贝时遇到性能问题,可以考虑通过手动编写深拷贝逻辑替代反射、使用对象池或预分配的方式缓存并优化内存分配,减少深拷贝的次数,甚至是针对复杂类型或数据结构的并发拷贝来优化,这些需要视具体场景来确定优化策略,这里就不展开了。

5. 深拷贝(Deep Copy)vs. 克隆(Clone)

最后再来说一下深拷贝(Deep Copy)和克隆(Clone)。它们都是复制对象的概念,但它们在概念和实现细节上存在一些差异。

通过上面说明,我们知道深拷贝是一种递归的复制过程,不仅复制对象本身,还会复制该对象所有引用的其他对象。这意味着所有的对象层级都会被独立地复制,最终形成一个完全独立的新对象,原对象和拷贝之间不存在任何共享的内存。

而克隆是指复制一个对象。其行为依赖于具体语言的实现方式。对于某些语言,克隆可能指的是浅拷贝(Shallow Copy),即只复制对象的基础数据字段,引用类型字段仍然指向原始对象。也有些语言将克隆定义为深拷贝,取决于上下文。比如在Java中,Object类提供了clone()方法,默认是浅拷贝,用户可以通过实现Cloneable接口来自定义克隆的行为,比如实现为深拷贝的逻辑。

因此,当目标对象在结构上与原对象一致的情况下,可以将深拷贝理解为一种特定类型的克隆。但在一些场景下(比如RPC),深拷贝不仅仅是简单的在内存中深度复制自身,而是需要考虑源对象和目的对象之间的结构差异和数据转换逻辑,本文并未覆盖这类场景,大家可以自行脑补。

5. 小结

在本文中,我们深入探讨了Go语言中的深拷贝概念、实现方法以及局限性。深拷贝在需要对象之间完全独立的场景中尤为重要,尤其是在防止意外修改共享数据、并发编程、不可变对象需求、回滚机制等情况下。我们介绍了手动实现深拷贝、利用反射的通用深拷贝方法以及使用第三方库的不同实现方式,并分析了每种方法的优缺点。

尽管深拷贝提供了数据的独立性和安全性,但在实现过程中也面临一些挑战,包括无法访问非导出字段、循环引用的问题,以及某些类型不支持拷贝的限制。性能问题也是一个需要考虑的因素,特别是在处理复杂数据结构时。

通过对深拷贝的理解,我希望大家能够在实际开发中更有效地使用这一技术,并根据具体需求选择合适的实现方式,从而优化代码质量和程序性能。


Gopher部落知识星球在2024年将继续致力于打造一个高品质的Go语言学习和交流平台。我们将继续提供优质的Go技术文章首发和阅读体验。同时,我们也会加强代码质量和最佳实践的分享,包括如何编写简洁、可读、可测试的Go代码。此外,我们还会加强星友之间的交流和互动。欢迎大家踊跃提问,分享心得,讨论技术。我会在第一时间进行解答和交流。我衷心希望Gopher部落可以成为大家学习、进步、交流的港湾。让我相聚在Gopher部落,享受coding的快乐! 欢迎大家踊跃加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻) – https://gopherdaily.tonybai.com

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • Gopher Daily归档 – https://github.com/bigwhite/gopherdaily
  • Gopher Daily Feed订阅 – https://gopherdaily.tonybai.com/feed

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats