1 引言
golang作为一门现代语言,有其独特之处,比如一个go func(){}()
语句即可实现协程,但也存在一些让人诟病的地方,比如错误处理等等。但是想必人无完人,无物完物。我们今天聊聊golang的协程(也叫goroutine)。首先提到协程,我们会想到进程,线程,那么协程是什么呢?协程是一种用户态的线程,他可以由用户自行创建和销毁,不需要内核调度,创建和销毁不需要占用太多系统资源的用户态线程。所以通常情况下,对于大并发的业务,我们通常能创建数以万计的协程来并发处理我们的业务,而不用担心资源占用过多。所以go的协程的作用就是为了实现并发编程,它是由go自己实现的调度器实现资源调度,从而开发者不用太多关心并发实现,从而可以安心的写一些牛逼的业务代码。
本文将从golang的协程定义,特点,如何创建和退出,以及会聊聊go的协程实现原理,即依托老生常谈的调度器GPM,一起了解和学习golang的协程机制。
2 golang协程的概念
2.1 定义和特点:
我们看下Effective go 给的描述1:
They're called goroutines because the existing terms—threads, coroutines, processes, and so on—convey inaccurate connotations. A goroutine has a simple model: it is a function executing concurrently with other goroutines in the same address space. It is lightweight, costing little more than the allocation of stack space. And the stacks start small, so they are cheap, and grow by allocating (and freeing) heap storage as required. Goroutines are multiplexed onto multiple OS threads so if one should block, such as while waiting for I/O, others continue to run. Their design hides many of the complexities of thread creation and management.Prefix a function or method call with the go keyword to run the call in a new goroutine. When the call completes, the goroutine exits, silently. (The effect is similar to the Unix shell's & notation for running a command in the background.)
所以在开头我们也如是说。
协程的特点可以概括为:
1.轻量:因为其与操作系统级的线程对比,她的创建和开销是很小的,以至于我们可以创建成千上万个。
2.并发:因为是用户态的线程,他可以被用户随意的创建和执行多种task,可以最大限度地利用系统的资源/网络资源等,能实现高吞吐和高性能。
3.易用:开发者可以使用go关键字 一个func(){}实现一个协程,相较于其他语言十分的便捷。我们也可以在不想使用时添加销毁机制销毁它。
4.channel:go协程可以利用channel进行协程间安全地通信,共享数据等
5.灵活调度:go自身实现调度机制,其可以根据系统环境情况,系统负载情况实现自动调度。
6.安全:因为其简化了并发模型,所以用户可以很少地写出并发程序中的bug
7.有独立的栈空间,共享程序堆空间
总之,go协程是一个轻量级,高并发,用户友好的用户态线程。
2.2 简单创建和退出协程示例
接下来我们来看如何创建一个go协程,示例代码:
代码语言:go复制package main
import (
"fmt"
"time"
)
func showTime() {
fmt.Printf("current time: %vn", time.Now())
}
func main() {
go showTime()
time.Sleep(2 * time.Second)
}
输出:
代码语言:shell复制[Running] go run "/Users/xxx/go-proj/go_test/test.go"
current time: 2024-02-04 17:18:38.797173 0800 CST m= 0.000186767
上述我们定一个函数showTime
,使用go
关键字创建了一个协程,并成功打印的当前时间。
退出协程:
正常情况下我们不需要关心协程的销毁,但是有些例外让我们需要主动退出。比如正常业务需要,避免协程泄漏等。
业务场景销毁协程示例:
代码语言:go复制package main
import (
"fmt"
"time"
)
func showTime(stopChan chan bool) {
for {
select {
case <-stopChan: // 接收到退出命令
fmt.Println("stoped")
return
default:
fmt.Println("running")
}
time.Sleep(1 * time.Second)
}
}
func main() {
stop := make(chan bool)
go showTime(stop)
time.Sleep(3 * time.Second)
close(stop) // 发送退出命令
time.Sleep(5 * time.Second)
}
执行效果:
代码语言:shell复制[Running] go run "/Users/xxx/go-proj/go_test/test.go"
running
running
running
stoped
上述我们创建了一个showTime的task,并使用for-select监听信号,我们在主协程3秒后关闭channel,select接受到此信号退出协程。
select是一个类似c 里的select,可以实现多路IO复用机制,我们一般用来做超时控制,信号监听等。
此外我们也可以使用context包去实现协程管理。
协程泄漏场景示例:
代码语言:go复制package main
import (
"fmt"
"runtime"
"time"
)
func showTime(G string) string {
now := time.Now().String()
fmt.Printf("%s:current time: %vn", G, now)
return now
}
func queryTime() {
res := make(chan string)
go func() {
res <- showTime("G1")
}()
go func() {
res <- showTime("G2")
}()
go func() {
res <- showTime("G3")
}()
fmt.Printf("get res:%vn", <-res)
time.Sleep(5 * time.Second)
}
func main() {
for i := 0; i < 3; i {
queryTime()
fmt.Printf("num of goroutine:%vn", runtime.NumGoroutine())
}
}
运行结果:
代码语言:shell复制[Running] go run "/Users/ljw4010/go-proj/go_test/test.go"
G3:current time: 2024-02-04 22:12:41.9643 0800 CST m= 0.000264400
get res:2024-02-04 22:12:41.9643 0800 CST m= 0.000264400
G2:current time: 2024-02-04 22:12:41.96443 0800 CST m= 0.000394662
G1:current time: 2024-02-04 22:12:41.964413 0800 CST m= 0.000377112
num of goroutine:3
G3:current time: 2024-02-04 22:12:46.970056 0800 CST m= 5.005870197
get res:2024-02-04 22:12:46.970056 0800 CST m= 5.005870197
G2:current time: 2024-02-04 22:12:46.97011 0800 CST m= 5.005924380
G1:current time: 2024-02-04 22:12:46.970096 0800 CST m= 5.005910525
num of goroutine:5
G3:current time: 2024-02-04 22:12:51.975522 0800 CST m= 10.011185907
get res:2024-02-04 22:12:51.975522 0800 CST m= 10.011185907
G2:current time: 2024-02-04 22:12:51.975584 0800 CST m= 10.011248232
G1:current time: 2024-02-04 22:12:51.975533 0800 CST m= 10.011197362
num of goroutine:7
上面执行结果可以看出协程数量是在不断增加,但是理论上,每次循环协程数量应该都是从0开始增加的,我们每次开三个协程,所以每次计算的协程数量最多是3,但是上述有5,7,只能说明协程泄漏了,之前创建的协程没有释放。那么如何修复呢?
上述协程泄漏的原因是我们每次在queryTime中开三个协程,但是res
是无缓冲的,所以下面<-res
只能接收到跑的最快的那个,其他的两个就被阻塞了,因为没人要啊!
所以解决这个问题,要么我们给他们各找个接受对象,要么用缓冲的channal就可以了。
找对象的示例:
代码语言:go复制package main
import (
"fmt"
"runtime"
"time"
)
func showTime(G string) string {
now := time.Now().String()
fmt.Printf("%s:current time: %vn", G, now)
return now
}
func queryTime() {
res := make(chan string)
go func() {
res <- showTime("G1")
}()
go func() {
res <- showTime("G2")
}()
go func() {
res <- showTime("G3")
}()
fmt.Printf("get res1:%vn", <-res)
fmt.Printf("get res2:%vn", <-res)
fmt.Printf("get res3:%vn", <-res)
time.Sleep(5 * time.Second)
}
func main() {
for i := 0; i < 3; i {
queryTime()
fmt.Printf("num of goroutine:%vn", runtime.NumGoroutine())
}
}
结果输出:
代码语言:shell复制[Running] go run "/Users/ljw4010/go-proj/go_test/test.go"
G3:current time: 2024-02-04 22:24:11.477974 0800 CST m= 0.000117442
get res1:2024-02-04 22:24:11.477974 0800 CST m= 0.000117442
G1:current time: 2024-02-04 22:24:11.477977 0800 CST m= 0.000120086
get res2:2024-02-04 22:24:11.477977 0800 CST m= 0.000120086
G2:current time: 2024-02-04 22:24:11.477986 0800 CST m= 0.000129317
get res3:2024-02-04 22:24:11.477986 0800 CST m= 0.000129317
num of goroutine:1
G3:current time: 2024-02-04 22:24:16.483818 0800 CST m= 5.005811611
get res1:2024-02-04 22:24:16.483818 0800 CST m= 5.005811611
G1:current time: 2024-02-04 22:24:16.483876 0800 CST m= 5.005869366
get res2:2024-02-04 22:24:16.483876 0800 CST m= 5.005869366
G2:current time: 2024-02-04 22:24:16.483904 0800 CST m= 5.005897378
get res3:2024-02-04 22:24:16.483904 0800 CST m= 5.005897378
num of goroutine:1
G3:current time: 2024-02-04 22:24:21.485658 0800 CST m= 10.007501449
get res1:2024-02-04 22:24:21.485658 0800 CST m= 10.007501449
G2:current time: 2024-02-04 22:24:21.485692 0800 CST m= 10.007535003
get res2:2024-02-04 22:24:21.485692 0800 CST m= 10.007535003
G1:current time: 2024-02-04 22:24:21.485713 0800 CST m= 10.007555908
get res3:2024-02-04 22:24:21.485713 0800 CST m= 10.007555908
num of goroutine:1
可以看出结果正确了。所以这里需要正确退出协程,避免资源浪费。
2.3 go协程结构代码定义及调度初探
下面的代码可以在go源码的runtime2.go文件中找到关于协程结构体,以及其相关参数:
G(groutine):
代码语言:go复制type g struct {
// 这里是协程的栈
stack stack // offset known to runtime/cgo
stackguard0 uintptr // offset known to liblink
stackguard1 uintptr // offset known to liblink
// m是对应内核线程的指针
m *m // current m; offset known to arm liblink
// 协程的调度状态
sched gobuf
.
.
.
// 协程id
goid uint64
// 协程抢占相关的概念
preempt bool // preemption signal, duplicates stackguard0 = stackpreempt
preemptStop bool // transition to _Gpreempted on preemption; otherwise, just deschedule
preemptShrink bool // shrink stack at synchronous safe point
.
.
.
}
M:
代码语言:go复制type m struct {
// 系统管理的一个g,执行调度代码时使用的。比如执行用户的goroutine时,就需要把把用户
// 的栈信息换到内核线程的栈,以便能够执行用户goroutine
//用于执行调度器的g0
g0 *g // goroutine with scheduling stack
morebuf gobuf // gobuf arg to morestack
divmod uint32 // div/mod denominator for arm - known to liblink
// Fields not known to debuggers.
procid uint64 // for debuggers, but offset not hard-coded
//处理signal的 g
gsignal *g // signal-handling g
goSigStack gsignalStack // Go-allocated signal handling stack
sigmask sigset // storage for saved signal mask
//线程的本地存储TLS,这里就是为什么OS线程能运行M关键地方
tls [6]uintptr // thread-local storage (for x86 extern register)
//go 关键字运行的函数
mstartfn func()
//当前运行的用户goroutine的g结构体对象
curg *g // current running goroutine
caughtsig guintptr // goroutine running during fatal signal
//当前工作线程绑定的P,如果没有就为nil
p puintptr // attached p for executing go code (nil if not executing go code)
//暂存与当前M潜在关联的P
nextp puintptr
//M之前调用的P
oldp puintptr // the p that was attached before executing a syscall
id int64
mallocing int32
throwing int32
//当前M是否关闭抢占式调度
preemptoff string // if != "", keep curg running on this m
locks int32
dying int32
profilehz int32
//M的自旋状态,为true时M处于自旋状态,正在从其他线程偷G; 为false,休眠状态
spinning bool // m is out of work and is actively looking for work
blocked bool // m is blocked on a note
newSigstack bool // minit on C thread called sigaltstack
printlock int8
incgo bool // m is executing a cgo call
freeWait uint32 // if == 0, safe to free g0 and
.
.
.
//没有goroutine运行时,工作线程睡眠
//通过这个来唤醒工作线程
park note // 休眠锁
//记录所有工作线程的链表
alllink *m // on allm
schedlink muintptr
//当前线程内存分配的本地缓存
mcache *mcache
//当前M锁定的G,
lockedg guintptr
.
.
.
//操作系统线程id
thread uintptr // thread handle
.
.
.
}
P:
代码语言:go复制type p struct {
//allp中的索引
id int32
//p的状态
status uint32 // one of pidle/prunning/...
link puintptr
schedtick uint32 // incremented on every scheduler call->每次scheduler调用 1
syscalltick uint32 // incremented on every system call->每次系统调用 1
sysmontick sysmontick // last tick observed by sysmon
//指向绑定的 m,如果 p 是 idle 的话,那这个指针是 nil
m muintptr // back-link to associated m (nil if idle)
mcache *mcache
raceprocctx uintptr
//不同大小可用defer结构池
deferpool [5][]*_defer // pool of available defer structs of different sizes (see panic.go)
deferpoolbuf [5][32]*_defer
// Cache of goroutine ids, amortizes accesses to runtime·sched.goidgen.
goidcache uint64
goidcacheend uint64
//本地运行队列,可以无锁访问
// Queue of runnable goroutines. Accessed without lock.
runqhead uint32 //队列头
runqtail uint32 //队列尾
//数组实现的循环队列
runq [256]guintptr
// runnext 非空时,代表的是一个 runnable 状态的 G,
//这个 G 被 当前 G 修改为 ready 状态,相比 runq 中的 G 有更高的优先级。
//如果当前 G 还有剩余的可用时间,那么就应该运行这个 G
//运行之后,该 G 会继承当前 G 的剩余时间
runnext guintptr
// Available G's (status == Gdead)
//空闲的g
gFree struct {
gList
n int32
}
.
.
.
}
上面是协程及其相关的基本结构定义,我们看到了协程栈,协程当前绑定的OS线程m等信息,m又绑定一个p去处理g,实际上在go语言中,前面也提到的,go自己实现的的调度模型,用来生成,调度,销毁协程,那就是大名鼎鼎的GPM模型。
上述模型直接实现了对协程从产生到消失的生命周期的管理,其中G就是协程goroutine,前面说过G是用户态的,OS不可见,而M是内核态的线程,G的运行实际上依托于M,借助系统将其调度给CPU运行,为了方便协程的调度go抽象出来一个逻辑处理器P,P就是负责管理一组G在M上能够高效运行。详细的介绍可以参考官方的介绍
3 Go协程的调度
本节要重点介绍下GMP模型和go调度策略,但是翻看网上各位大佬的文章,我还是默默地五体投地了,这里贴上刘丹冰老师的文章以供膜拜:https://mp.weixin.qq.com/s/9MfIdUdBZmfqbUYT_xrB8A,这里也贴上我理解后做的一些动画解释,也方便大家学习。
当然上面视频也是非常浅的介绍,详细的可以参考其他文章或者书籍。
4 go协程的通信机制
4.1 概念
go语言里有句名言,“不要通过共享内存来通信,而应该通过通信来共享内存”。传统的共享内存,常常是多个进程或者线程同时访问一个内存块,可能出现资源竞争,数据不一致等问题,go的先进理念是通过channel来进行通信,channel就是一个类似FIFO的队列,两端连着不同的goroutine,可以实现发送和接收数据,同时其操作都是原子态的不用担心安全问题,所以可以安心滴进行共享数据。
我们可以这样操作一个channel:
代码语言:go复制// 定义
var ch chan 元素类型
// 初始化
ch=make(chan 元素类型, [容量])
// 发送
ch <- 1
// 读取
x:=<-ch
// 关闭
close(ch)
元素类型就是常见的int,string等,初始化时容量如果不填写,那么就称为阻塞类型的channel,否则就是非阻塞类型的channel。
4.2 阻塞和非阻塞
channel分为阻塞和非阻塞,上述初始化容量不设置就是一个阻塞类型的channel,也即是没有缓冲,如果设置了那就是有缓冲。举个不恰当的栗子。神话故事中都有穿越,穿越的时候可能有一道幕墙,如果幕墙很薄,那么你要么可以从现代一下子穿到未来,要么传不过去,要看你法力了;但是有些是时光隧道,那么你需要经过这个很长的隧道才能到达未来。
阻塞示例:
代码语言:go复制package main
import (
"fmt"
)
func main() {
var ch = make(chan int)
ch <- 1
fmt.Println(<-ch)
}
输出:
代码语言:shell复制[Running] go run "/Users/xxx/go-proj/go_test/test.go"
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/Users/ljw4010/go-proj/go_test/test.go:9 0x37
exit status 2
上述代码报错死锁了,因为我们只定义和发送了数据,但是没有接收的,所以必须有接受的才能进行发送.有人说我代码不是有接受的么<-ch
,是的,但是他们都是在main函数中,ch发送的数据后如果没人接受就卡死那里了,根本走不到下一步。所以我们可以这样改正:
package main
import (
"fmt"
"time"
)
func main() {
var ch = make(chan int)
go func(chan int) {
x := <-ch
fmt.Printf("x=%dn", x)
}(ch)
ch <- 1
time.Sleep(1 * time.Second)
}
执行结果:
代码语言:go复制[Running] go run "/Users/xxx/go-proj/go_test/test.go"
x=1
非阻塞示例:
代码语言:go复制package main
import (
"fmt"
"runtime"
"time"
)
func showTime(G string) string {
now := time.Now().String()
return fmt.Sprintf("%s-%s", G, now)
}
func queryTime() {
res := make(chan string, 3)
go func() {
res <- showTime("G1")
}()
go func() {
res <- showTime("G2")
}()
go func() {
res <- showTime("G3")
}()
for i := 0; i < 3; i {
fmt.Printf("get res:%vn", <-res)
}
time.Sleep(5 * time.Second)
}
func main() {
for i := 0; i < 3; i {
queryTime()
fmt.Printf("num of goroutine:%vn", runtime.NumGoroutine())
}
}
结果输出:
代码语言:shell复制[Running] go run "/Users/xxx/go-proj/go_test/test.go"
get res:G3-2024-02-06 00:49:34.8439 0800 CST m= 0.000137832
get res:G1-2024-02-06 00:49:34.843916 0800 CST m= 0.000153421
get res:G2-2024-02-06 00:49:34.843924 0800 CST m= 0.000161770
num of goroutine:1
get res:G3-2024-02-06 00:49:39.8487 0800 CST m= 5.004787351
get res:G1-2024-02-06 00:49:39.848759 0800 CST m= 5.004845999
get res:G2-2024-02-06 00:49:39.848787 0800 CST m= 5.004874474
num of goroutine:1
get res:G3-2024-02-06 00:49:44.849213 0800 CST m= 10.005150501
get res:G2-2024-02-06 00:49:44.849261 0800 CST m= 10.005198616
get res:G1-2024-02-06 00:49:44.849234 0800 CST m= 10.005171291
num of goroutine:1
这是个上面协程泄漏的例子,在这里我们使用带缓冲的channel修复了。
5 Go协程应用场景和案例
5.1 常见场景
因为go的轻量级线程的优点,他已被使用在很多开发场景中,比如典型的web开发,后端开发做一些并行计算,分布式系统,以及平台/工具库等。
5.2 高并发的web
因为协程的特性,让他去处理HTTP服务器的每个请求是最好不过的事了,我们可以为每个请求创建一个协程来处理,这样就可以实现高并发的web服务。而go本身就自带http的package,所以生产率可以很高。
当然现在市面上也有很多二次封装的比较优秀的web框架,诸如gin,Beego,echo,iris等各具优势特点,可以根据自己的需求选用。
5.3 容器及高并发分布式任务
随着大数据的兴起,容器化的发展,很多新兴的平台工具也基本利用go协程特点实现,比如docker/K8s就是基于go编写,当然也少不了对协程的利用,另外注入go-job分布式调度系统可以实现高并发的任务处理。
6 协程使用注意事项
6.1 常见问题
在前面几章我提到了协程泄露,但是由于协程极易创建,所以对于不熟悉的人来说也极易出现问题。常见的问题有:
1.资源泄露:比如文件没有被正确关闭等
2.死锁:多个协程无限期等待对方释放资源,但是对方一直占有
3.协程泄露:就是上面提到的
4.竟态条件:多个协程同时读写一个资源
5.协程调度开销:大业务量等创建大量协程
6.2 最佳实践
协程易用但也要悠着点,除了上面提到的要避免协程泄露,发送数据尽量使用channel,正确的编码习惯,下面列举下常见的方法。
6.2.1 协程数量控制
常见的协程数量控制方法分为两种,一种是基于channel的缓冲机制实现,另外一种是利用go的channel waitGroup,以下是示例,代码来自网络。
方法一:
代码语言:go复制package main
import (
"fmt"
"time"
)
// 同时最多10个协程运行
var limitMaxNum = 10
var chData = make(chan int, limitMaxNum)
// 有100个任务要处理
var tasknum = 100
// 使用 有缓冲容量长度的channel
func main() {
var i, j int
var chanRet = make(chan int, tasknum) //运行结果存储到chanRet
//运行处理
go func() {
for i = 0; i < tasknum; i {
chData <- 1
go dotask(i, chanRet)
}
}()
//获取返回结果
for j = 0; j < tasknum; j {
<-chData
<-chanRet
// fmt.Println("ret:", ret)
}
fmt.Println("main over")
}
func dotask(taskid int, chanRet chan int) {
time.Sleep(time.Millisecond * 100)
fmt.Println("finish task ", taskid)
chanRet <- taskid * taskid
}
这个代码开始定义了两个chan,chData是用来控制并发的最大协程数量,chanRet是存储每个协程的结果。
chData就好比一个队列,里面最大容量10,也就是最多并发10个协程,处理完的销毁,腾出位置,新的协程进入处理。
方法二:
代码语言:go复制package main
import (
"fmt"
"sync"
"time"
)
var limitMaxNum = 10
var chData = make(chan int, limitMaxNum)
var jobGroup sync.WaitGroup
var tasknum = 100
// 使用 有缓冲容量长度的channel
func main() {
var i int
//var chanRet = make(chan int, tasknum)
//处理任务,最多同时有10个协程
for i = 0; i < tasknum; i {
chData <- 1
go dotask(i)
}
//使用Wait等待所有任务执行完毕
jobGroup.Wait()
fmt.Println("main over")
}
func dotask(taskid int) {
jobGroup.Add(1)
time.Sleep(time.Millisecond * 100)
fmt.Println("finish task ", taskid)
// fmt.taskid * taskid
<-chData
jobGroup.Done()
}
这个方法其实也是channel方法演变出来的,都是利用channel的缓冲,只是这里使用WaitGroup,先把任务都分发出去,后面只需要结构就可以了。
6.2.2 互斥锁保护共享资源
互斥锁的作用就是避免资源被同时共享,只能一个协程占用,这对共享数据的读写是非常有必要的。下面一个简单示例:
代码语言:go复制package main
import (
"fmt"
"sync"
)
var sharedData int
var mu sync.Mutex
func updateData() {
mu.Lock()
sharedData
mu.Unlock()
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 100; i {
wg.Add(1)
go func() {
defer wg.Done()
updateData()
}()
}
wg.Wait()
fmt.Println("Shared Data:", sharedData)
}
锁定义在sync
包,有Lock和Unlock两个方法,前者用来锁资源,后者用来释放资源。上述sharedData
如果不加锁,就有可能同时被多个协程更新。
6.2.3 使用context进行协程控制
context包是go新引入的包,主要就是用来控制协程。其包含方法:
WithCancel
、WithDeadline
/WithTimeout
、WithValue
等。
下面一个示例演示如何退出一个协程:
代码语言:go复制package main
import (
"context"
"fmt"
"time"
)
func work(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("exit")
return
default:
time.Sleep(1 * time.Second)
fmt.Println("running")
}
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
go work(ctx)
time.Sleep(5 * time.Second)
cancel()
fmt.Println("work was cancelled")
time.Sleep(1 * time.Second)
}
输出:
代码语言:go复制[Running] go run "/Users/xxx/go-proj/go_test/test.go"
running
running
running
running
work was cancelled
running
exit
上述方法首先拿到了一个ctx实例,在把它传给协程,在协程中监听ctx上层发来的信号,接收到信号就结束当前任务,收拾收拾回家过年。
7 总结
本文介绍了go和go协程的定义,和使用;同时也阐述了go的协程的实现原理,即依赖于运行时调度器,通过类似M:N调度模型,通过合适的调度策略,实现了协程的全生命周期的管理;另外介绍了协程的一些特性和最佳实践,诸如channel通信机制,如何控制协程数量,如何控制协程等。
此外,本文还在难理解的地方给出了动画视频,适当地方给出了实例,方便大家理解。
附录:
1.https://golang.google.cn/doc/effective_go#goroutines
我正在参与2024腾讯技术创作特训营第五期有奖征文,快来和我瓜分大奖!