本文主要内容为实现《GO语言工程实践》课程课后作业,主要展示个人思路,代码以及部分思考,文章末尾展示了作业实现过程中如何使用豆包MarsCode进行变成辅助理解和思路指导。
如有错误,希望多多指教。
课后作业要求
- 支持发布帖子;
- 本地ID生成需要保证不重复、唯一性;
- Append文件,更新索引,注意Map的并发安全问题;
前期准备
- 开发环境:
- 开发环境:WSL;
- 操作系统:Ubuntu;
- 代码编辑器:VSCode。
- 代码库准备:使用
git clone [repository-url]命令克隆代码库至本地开发环境; - 了解代码库结构:使用
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 update或sudo 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的操作步骤
- 线程A尝试获取互斥锁
idLock.Lock()并成功。- 线程A检查
nextPostId,发现它是0。- 线程A设置
nextPostId为当前时间戳(例如:1633036800000000000)。- 线程调度器切换到线程B(在线程A增加
nextPostId之前)。线程B的操作步骤
- 线程B等待互斥锁(因为线程A持有锁)。
- 线程调度器切换回线程A。
线程A继续操作
- 线程A将
nextPostId增加1(现在nextPostId= 1633036800000000001)。- 线程A释放互斥锁
idLock.Unlock()。线程B继续操作
- 线程B获取互斥锁
idLock.Lock()。- 线程B检查
nextPostId,发现它已经是时间戳,不是0。- 线程B将
nextPostId增加1(现在nextPostId= 1633036800000000002)。- 线程B释放互斥锁
idLock.Unlock()。结果
- 线程A生成的ID是 1633036800000000001。
- 线程B生成的ID是 1633036800000000002。
在这个例子中,由于线程调度器的切换,两个线程最终生成了不同的ID。但如果线程B在线程A设置时间戳之前切换,那么两个线程可能会生成相同的ID。
不使用原子操作生成相同ID情况
初始状态
nextPostId= 0- 互斥锁
idLock未锁定线程A的操作步骤
- 线程A尝试获取互斥锁
idLock.Lock()并成功。- 线程调度器切换到线程B(在线程A设置
nextPostId之前)。线程B的操作步骤
- 线程B等待互斥锁(因为线程A持有锁)。
线程A继续操作
- 线程A设置
nextPostId为当前时间戳(例如:1633036800000000000)。线程调度器切换回线程B
此时,线程B开始执行,但由于线程A已经持有锁,线程B必须等待。
线程A继续操作
- 线程A将
nextPostId增加1(现在nextPostId= 1633036800000000001)。- 线程A释放互斥锁
idLock.Unlock()。线程B继续操作
- 线程B获取互斥锁
idLock.Lock()。- 关键点:线程B检查
nextPostId,发现它已经是时间戳,但线程B并不知道线程A已经增加过1,因此线程B可能会错误地认为它应该从时间戳开始,而不是从线程A增加后的值开始。错误发生
- 线程B错误地将
nextPostId设置为当前时间戳(例如:1633036800000000000),因为它没有意识到线程A已经修改过这个值。- 线程B将
nextPostId增加1(现在nextPostId= 1633036800000000001),这与线程A生成的ID相同。结果
- 线程A生成的ID是 1633036800000000001。
- 线程B生成的ID也是 1633036800000000001。
使用原子操作的情况
初始状态
nextPostId= 0- 互斥锁
idLock未锁定线程A的操作步骤
- 线程A尝试获取互斥锁
idLock.Lock()并成功。- 线程A检查
nextPostId,发现它是0。- 线程A设置
nextPostId为当前时间戳(例如:1633036800000000000)。- 线程调度器切换到线程B。
线程B的操作步骤
- 线程B等待互斥锁(因为线程A持有锁)。
- 线程调度器切换回线程A。
线程A继续操作
- 线程A使用原子操作将
nextPostId增加1(现在nextPostId= 1633036800000000001)。- 线程A释放互斥锁
idLock.Unlock()。线程B继续操作
- 线程B获取互斥锁
idLock.Lock()。- 线程B发现
nextPostId已经被初始化,不是0。- 线程B使用原子操作将
nextPostId增加1(现在nextPostId= 1633036800000000002)。- 线程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
}
豆包使用感受
代码知道方面真的很全面,省了很多查询资料的时间,但是感觉在讲解方面功能较弱,我希望他给我思路引导的时候,明确告知了不要给我代码,给我思路引导时候,生成内容还是有代码,比较影响体验,而且对于讲解,明确讲解角度和重点的时候,生成内容与预期不符,对于理解他生成的修改代码,我个人使用的时候还是要结合其他工具辅助理解。
反思
对于结合工程整体思考修改方面还是欠缺,代码能力较弱,实现思路不完善,还需要多练习