GO语言工程实践课后作业:实现思路、代码以及路径记录(2) | 豆包MarsCode AI刷题

100 阅读10分钟

本文主要内容为实现《GO语言工程实践》课程课后作业,主要展示个人思路,代码以及部分思考,文章末尾展示了作业实现过程中如何使用豆包MarsCode进行变成辅助理解和思路指导。
如有错误,希望多多指教。

课后作业要求

  1. 支持发布帖子;
  2. 本地ID生成需要保证不重复、唯一性;
  3. Append文件,更新索引,注意Map的并发安全问题;

前期准备

  1. 开发环境
  • 开发环境:WSL;
  • 操作系统:Ubuntu;
  • 代码编辑器:VSCode。
  1. 代码库准备:使用git clone [repository-url]命令克隆代码库至本地开发环境;
  2. 了解代码库结构:使用tree /···/go-project-example-main命令,了解文件结构,结构如下所示。
├── LICENSE
├── README.md
├── avatar.jpg
├── example.sql
├── go.mod
├── go.sum
├── handler
│   ├── publish_post.go
│   └── query_page_info.go
├── repository
│   ├── db_init.go
│   ├── post.go
│   ├── topic.go
│   └── user.go
├── service
│   ├── publish_post.go
│   ├── publish_post_test.go
│   ├── query_page_info.go
│   └── query_page_info_test.go
├── sever.go
└── util
    └── logger.go

4 directories, 18 files

(PS:如果环境没有安装tree命令,可以使用sudo apt-get updatesudo apt-get install tree命令安装tree命令(Ubuntu),或者可以使用ls -R命令来查看文件夹的递归列表。)

开始实现

观察代码库,我们可以了解到service库下的publish_post.go 文件中已经实现了发布帖子的功能,我们可以继续优化和扩展这个功能。
我们先把注意力集中到课后作业第二点上。

实现:本地ID生成需要保证不重复、唯一性

思路

一开始我想的是让ID为帖子创建时间,但当同一时间发布多个帖子的时候,在多线程背景下,其实ID是不能保证唯一性的,于是我们需要找到一种方法来保证ID生成的线程安全。这里采用的sync.Mutex互斥锁原子操作来实现。

代码

var(
	nextPostId int 64//存储下一个ID
	idLock sync.Mutex//互斥锁
)

func GenerateUniqueId() int64{//用于生成一个唯一的ID
	idLock.Lock()
	defer idLock.Unlock()
	//初始化nextPostId为当前时间戳
	if nextPostId == 0 {
		nextPostId = time.Now().UnixNano()
	}
	atomic.AddInt64(&nextPostId,1)
	return nextPostId
}
说明

1. 使用互斥锁idLock保证在多线程环境下ID生成的原子性与线程安全。

  • 原子性:一系列操作要么全部执行成功,要么全部不执行,不会出现部分执行的情况。这确保了在操作执行过程中不会被其他线程或进程打断。
  • 互斥锁:保证在同一时间只有一个线程可以访问共享资源。当一个线程获取到互斥锁时,其他线程必须等待,直到该线程释放锁。运用互斥锁,保证为了确保在生成新ID时,nextPostId 的值能够原子性地增加。这意味着即使在多线程环境下,也只有一个线程能够成功地增加 nextPostId 的值,从而避免了ID冲突。

2. GenerateUniqueId()函数用于生成一个唯一的ID

  • 用互斥锁和原子操作保证并发安全
  • 疑惑:一开始只考虑了用互斥锁来生产,后面询问豆包,建议增加原子操作,但还是不太理解,明明互斥锁已经实现了唯一性了,一个时间只有一个线程能生成id,为什么还要后面用原子操作呢?询问豆包得到如下解释:

在多线程或多进程环境中,多个线程或进程可能同时尝试生成唯一ID。虽然互斥锁(idLock)可以确保在同一时间只有一个线程可以访问nextPostId变量,但它并不能保证在获取锁和释放锁之间的操作是原子性的。

原子操作是指在执行过程中不会被其他操作打断的操作。在这个上下文中,原子操作atomic.AddInt64(&nextPostId, 1)确保了即使在多线程环境中,对nextPostId的自增操作也是原子性的,即要么完全执行,要么完全不执行,不会出现部分执行的情况。

因此,尽管互斥锁保证了对nextPostId的访问是串行化的,但原子操作进一步确保了自增操作本身的原子性,从而避免了在多线程环境中可能出现的竞态条件(race condition)。这就是为什么在初始化之后,仍然需要使用原子操作来增加nextPostId的值。

  • 理解如下(结合询问AI得到的例子说明):

不使用原子操作的情况

初始状态
  • nextPostId = 0
  • 互斥锁 idLock 未锁定
线程A的操作步骤
  1. 线程A尝试获取互斥锁 idLock.Lock() 并成功。
  2. 线程A检查 nextPostId,发现它是0。
  3. 线程A设置 nextPostId 为当前时间戳(例如:1633036800000000000)。
  4. 线程调度器切换到线程B(在线程A增加 nextPostId 之前)。
线程B的操作步骤
  1. 线程B等待互斥锁(因为线程A持有锁)。
  2. 线程调度器切换回线程A
线程A继续操作
  1. 线程A将 nextPostId 增加1(现在 nextPostId = 1633036800000000001)。
  2. 线程A释放互斥锁 idLock.Unlock()
线程B继续操作
  1. 线程B获取互斥锁 idLock.Lock()
  2. 线程B检查 nextPostId,发现它已经是时间戳,不是0。
  3. 线程B将 nextPostId 增加1(现在 nextPostId = 1633036800000000002)。
  4. 线程B释放互斥锁 idLock.Unlock()
结果
  • 线程A生成的ID是 1633036800000000001。
  • 线程B生成的ID是 1633036800000000002。

在这个例子中,由于线程调度器的切换,两个线程最终生成了不同的ID。但如果线程B在线程A设置时间戳之前切换,那么两个线程可能会生成相同的ID。

不使用原子操作生成相同ID情况

初始状态
  • nextPostId = 0
  • 互斥锁 idLock 未锁定
线程A的操作步骤
  1. 线程A尝试获取互斥锁 idLock.Lock() 并成功。
  2. 线程调度器切换到线程B(在线程A设置 nextPostId 之前)。
线程B的操作步骤
  1. 线程B等待互斥锁(因为线程A持有锁)。
线程A继续操作
  1. 线程A设置 nextPostId 为当前时间戳(例如:1633036800000000000)。
线程调度器切换回线程B

此时,线程B开始执行,但由于线程A已经持有锁,线程B必须等待。

线程A继续操作
  1. 线程A将 nextPostId 增加1(现在 nextPostId = 1633036800000000001)。
  2. 线程A释放互斥锁 idLock.Unlock()
线程B继续操作
  1. 线程B获取互斥锁 idLock.Lock()
  2. 关键点:线程B检查 nextPostId,发现它已经是时间戳,但线程B并不知道线程A已经增加过1,因此线程B可能会错误地认为它应该从时间戳开始,而不是从线程A增加后的值开始。
错误发生
  1. 线程B错误地将 nextPostId 设置为当前时间戳(例如:1633036800000000000),因为它没有意识到线程A已经修改过这个值。
  2. 线程B将 nextPostId 增加1(现在 nextPostId = 1633036800000000001),这与线程A生成的ID相同。
结果
  • 线程A生成的ID是 1633036800000000001。
  • 线程B生成的ID也是 1633036800000000001。

使用原子操作的情况

初始状态
  • nextPostId = 0
  • 互斥锁 idLock 未锁定
线程A的操作步骤
  1. 线程A尝试获取互斥锁 idLock.Lock() 并成功。
  2. 线程A检查 nextPostId,发现它是0。
  3. 线程A设置 nextPostId 为当前时间戳(例如:1633036800000000000)。
  4. 线程调度器切换到线程B
线程B的操作步骤
  1. 线程B等待互斥锁(因为线程A持有锁)。
  2. 线程调度器切换回线程A
线程A继续操作
  1. 线程A使用原子操作将 nextPostId 增加1(现在 nextPostId = 1633036800000000001)。
  2. 线程A释放互斥锁 idLock.Unlock()
线程B继续操作
  1. 线程B获取互斥锁 idLock.Lock()
  2. 线程B发现 nextPostId 已经被初始化,不是0。
  3. 线程B使用原子操作将 nextPostId 增加1(现在 nextPostId = 1633036800000000002)。
  4. 线程B释放互斥锁 idLock.Unlock()
结果
  • 线程A生成的ID是 1633036800000000001。
  • 线程B生成的ID是 1633036800000000002。 在这个例子中,即使线程调度器在线程A的操作中间切换到线程B,由于使用了原子操作,线程A的操作不会被中断,因此两个线程都能够生成唯一的ID。原子操作确保了即使在多线程环境中,nextPostId的增加也是安全的,不会出现竞态条件。
  • 后续整合生成ID功能到Repository库中;

实现:Append文件,更新索引,注意Map的并发安全问题

思路

对于这个需求,没太理解清楚,先按照目前的理解来解决问题,定义了一个文件集合的结构体,包括了一个map,map中包含了索引和文件路径,和一个互斥锁保证并发安全;接下来定义初始化方法和Append方法。

代码

package service

import(
	"errors"
	"github.com/Moonlight-Zhao/go-project-example/repository"
	"time"
	"unicode/utf8"
	"sync/atomic"
	"sync"
)
//Append文件,更新索引,注意Map的并发安全问题
type File struct{
	indexMap map[int64]string//key:索引,value:路径
	mu sync.Mutex// 并发安全
}

//初始化indexMap
func NewFile()*File{
	return &File{
		indexMap: make(map[int64]string),
	}
}
//Append文件,更新索引
func(f *File)AppendFile(index int64,path string)error{
	//检查(index,string)是否存在,如果存在,返回提示,反之增加文件
	// 加锁以保证并发安全
	f.mu.Lock()
	defer f.mu.Unlock()
	// 检查索引是否已经存在
	if _, indexExists := f.indexMap[index]; indexExists {
		// 如果索引存在,返回错误
		return errors.New("index already exists")
	}

	// 检查路径是否已经存在
	for _, existingPath := range f.indexMap {
		if existingPath == path {
			// 如果路径存在,返回错误
			return errors.New("path already exists")
		}
	}
	// 添加文件
	f.indexMap[index] = path
	return nil
}

豆包使用感受

代码知道方面真的很全面,省了很多查询资料的时间,但是感觉在讲解方面功能较弱,我希望他给我思路引导的时候,明确告知了不要给我代码,给我思路引导时候,生成内容还是有代码,比较影响体验,而且对于讲解,明确讲解角度和重点的时候,生成内容与预期不符,对于理解他生成的修改代码,我个人使用的时候还是要结合其他工具辅助理解。

反思

对于结合工程整体思考修改方面还是欠缺,代码能力较弱,实现思路不完善,还需要多练习