自学内容网 自学内容网

Golang中读写锁的底层实现

1. 读写锁的概述

1.1 读写锁的功能及作用

读写锁是一种并发控制机制,它允许多个线程同时对共享资源进行读访问,但在进行写操作时需要互斥访问,以确保数据的一致性和完整性。读写锁的主要功能是提高系统在读多写少场景下的并发处理能力,从而提升整体性能。

读写锁在实际应用中扮演着重要的角色,可以有效地降低系统的并发访问冲突,提高系统的并发处理能力,同时也能够避免写操作对读操作的阻塞,从而减少了线程的等待时间,提升了系统的整体响应速度。

在这里插入图片描述

1.2 读写锁的特点及应用场景

读写锁的特点主要包括以下几点:

  • 读写锁允许多个线程同时对共享资源进行读访问,从而提高了系统的读并发能力,在读多写少的场景下表现出色。
  • 当有写操作发生时,读写锁将阻塞其他的读操作和写操作,确保写操作的原子性和一致性。
  • 读写锁适用于读多写少的场景,如缓存管理、数据库查询等,能够提高系统的并发处理能力,降低线程的竞争压力。

应用场景包括但不限于:

  • 系统中数据读取操作远远多于数据更新操作的场景,例如新闻网站的文章阅读和更新操作。
  • 缓存系统中的数据读取频繁,写入操作相对较少的情况。
  • 数据库中的查询频繁,更新操作相对较少的情况。

这些场景下,读写锁能够有效地提高系统的并发处理能力,降低线程的竞争压力,从而提升系统的整体性能。

以上是对读写锁的概述,通过对读写锁的功能及作用、特点及应用场景的描述,我们可以深入理解读写锁在并发编程中的重要作用,以及在实际应用中的价值所在。

2. 读写锁的基本实现

上一章节 中,我们简单介绍了读写锁的概念和应用场景。现在,让我们深入 Golang 的内部,探索读写锁的基本实现原理,并了解其使用规范、特性和局限性。

2.1 读写锁的操作与使用方法

Golang 标准库 sync 包提供了 RWMutex 类型来实现读写锁。RWMutex 的操作可以概括为以下几种:

操作描述
Lock()获取写锁,阻塞直到获取成功
Unlock()释放写锁
RLock()获取读锁,允许多个 goroutine 同时获取
RUnlock()释放读锁
2.1.1 使用示例

下面是一个简单的示例,展示了如何使用 RWMutex 保护共享资源:

package main

import (
"fmt"
"sync"
"time"
)

type Counter struct {
mu    sync.RWMutex
count int
}

func (c *Counter) Inc() {
c.mu.Lock()         // 获取写锁
defer c.mu.Unlock() // 函数结束时释放写锁
c.count++
}

func (c *Counter) Get() int {
c.mu.RLock()         // 获取读锁
defer c.mu.RUnlock() // 函数结束时释放读锁
return c.count
}

func main() {
counter := &Counter{}

// 多个 goroutine 并发读写
go func() {
for i := 0; i < 10; i++ {
counter.Inc()
time.Sleep(time.Millisecond)
}
}()
go func() {
for i := 0; i < 10; i++ {
fmt.Println("Count:", counter.Get())
time.Sleep(time.Millisecond * 2)
}
}()

time.Sleep(time.Second)
}

在上述示例中,Counter 结构体使用 RWMutex 保护 count 字段。Inc() 方法修改 count 时获取写锁,确保同一时刻只有一个 goroutine 可以修改数据。Get() 方法读取 count 时获取读锁,允许多个 goroutine 同时读取数据。

2.1.2 注意事项
  • 写锁优先: 当存在读锁时,新的写锁请求会被阻塞,直到所有读锁释放。这意味着在高并发读场景下,写操作可能会被长时间阻塞。
  • 死锁: 与互斥锁类似,不正确的使用方式会导致死锁。例如在持有读锁的情况下尝试获取写锁。

2.2 读写锁的特点和限制

2.2.1 特点
  • 并发读取: 允许多个 goroutine 同时读取共享资源,提高读取效率。
  • 写操作独占: 确保写操作的原子性和数据一致性。
  • 适用于读多写少场景: 在读操作远多于写操作的场景下,能够显著提高性能。
2.2.2 限制
  • 写饥饿: 当存在大量读操作时,写操作可能会被长时间阻塞,导致写饥饿问题。
  • 复杂性: 相较于互斥锁,读写锁的使用更加复杂,需要开发者更加谨慎地处理锁的获取和释放。

3. Golang 中的读写锁

并发编程中经常涉及到读写操作。读操作不会影响数据的并发性,但写操作需要保证数据的一致性。为此,Go 语言提供了 RWMutex 读写锁用于保护共享资源,实现高效的并发性。

3.1 RWMutex 结构体的定义

读写锁 RWMutex 是通过嵌套 Mutex 与 Cond 结构实现的,相关定义如下:

type RWMutex struct {
    w           Mutex  // 互斥锁
    writerSem   uint32 // Writer notification semaphore.
    readerSem   uint32 // Reader notification semaphore.
    readerCount int32  // Number of readers.
    readerWait  int32  // Waiting readers.
}

type Mutex struct {
    state int32
    sema  uint32
}

type Cond struct {
    noCopy noCopy
    L      *Locker
    notify notifyList
}

3.2 RWMutex 结构体包含的字段及功能

下面结合表格描述 RWMutex 结构体包含的主要字段及功能。

字段名类型功能
wMutex写操作互斥锁,用于控制多个写操作互斥。
writerSemuint32写操作信号量,用于告诉等待写锁的 goroutine 其它的 goroutine 正在读或写该锁。writerSem 和 readerSem 都是信号量,它们的值表示有多少个 goroutine 正在一等待写锁和读锁的释放。
readerSemuint32读操作信号量,用于告诉等待读锁的 goroutine 其它的 goroutine 正在写该锁。
readerCountint32当前读锁的持有数。
readerWaitint32等待读锁的 goroutine 数量。

这些字段及方法相互协作,实现了 RWMutex 的读写锁功能。

3.3 RWMutex 方法的实现

RWMutex 包含了以下几个方法,分别对应加读锁、解读锁、加写锁、解写锁以及重锁。下面将分别介绍这几个方法的实现。

3.3.1 RLoser/Locker 接口

RLoser/Locker 接口是一个 RWMutex 所有方法的接口,在部分实现 RWMutex 方法中需要访问该接口。

type RLocker interface {
    RLoser
    Lock()
    Unlock()
}

type RLoscker interface {
    RLock()
    RUnlock()
}

其中 RLocker 继承了 RLoscker 接口,并添加了 Lock 和 Unlock 方法。RLocker 接口表示对象既支持读操作又支持写操作。

3.3.2 RLock() 和 RUnlock()

RLock 方法实现加读锁操作,RUnlock 方法实现解读锁操作。这两个方法的实现非常简单,具体可以看下面的代码。

// RLock 加读锁操作
func (rw *RWMutex) RLock() {
    runtime_Semacquire(&rw.readerSem)
    // Increment reader count.
    n := atomic.AddInt32(&rw.readerCount, 1)
    // if there is a writer, or a wait for a writer waiting, wait.
    if rw.writerSem != 0 || n < 0 {
        // Slow path: semaphore acquire with contend on w.
        runtime_Semacquire(&rw.readerWait)
    }
    runtime_Semrelease(&rw.readerSem)
}

// RUnlock 解读锁操作
func (rw *RWMutex) RUnlock() {
    n := atomic.AddInt32(&rw.readerCount, -1)
    if n < 0 {
        if n == -1 || atomic.LoadUint32(&rw.writerSem) == 0 {
            panic("sync: RUnlock of unlocked RWMutex")
        }
        if atomic.AddUint32(&rw.readerWait, 1) == rw.writerSem>>1 {
            // The last reader unblocks the writer.
            runtime_Semrelease(&rw.writerSem)
        }
    }
}

读锁操作执行流程:

  1. 在获取读锁之前,要通过 Semaphore(rw.readerSem)和 atomic 操作保证同一时间只能有一个 goroutine 获取 rw.readerSem。
  2. 当 rw.writerSem 不为 0 时,说明有 goroutine 正在等待写锁,或者正在写锁,此时读锁阻塞。
  3. 如果 rw.writerSem 为 0 且当前读锁持有数大于等于 0 时,说明没有 goroutine 正在等待或持有写锁,读锁获取成功,返回。
  4. 如果读锁获取失败,需要通过 Semaphore(rw.readerWait)保证 goroutine 在等待读锁时串行结构,以避免 busy waiting。

读锁解锁操作执行流程:

  1. 当前读锁计数减 1,如果读锁计数为负数,则说明存在无效解锁操作,此时需要 panic 报错,否则继续执行下一步。
  2. 如果读锁计数减少之后仍大于等于 0,则说明还有其它 goroutine 持有读锁,不需要唤醒等待的 goroutine,解锁流程结束。
  3. 如果读锁计数减少之后等于 -1,则说明不存在读锁 hold,但存在写锁已经持有或等待该锁,此时需要抛出 panic 报错。
  4. 如果 rw.readerWait 计数值等于 (rw.writerSem >> 1),则说明最后一个等待读锁的 goroutine 解锁了读锁,此时需要唤醒等待写锁的 goroutine(if any)。
3.3.3 Lock() 和 Unlock()

Lock 方法实现加写锁操作,Unlock 方法实现释放写锁的操作。写锁的操作与读锁的操作比,稍微复杂一些。

// Lock 加写锁操作
func (rw *RWMutex) Lock() {
    rw.w.Lock()
    // Announce to readers there is a writer.
    r := atomic.AddUint32(&rw.readerSem, rwmutexMaxReaders) - rwmutexMaxReaders
    if r != 0 {
        // wait for readers.
        runtime_Semacquire(&rw.writerSem)
    }
}

// Unlock 解写锁操作
func (rw *RWMutex) Unlock() {
    if atomic.LoadUint32(&rw.readerSem) == rwmutexMaxReaders {
        // The lock is not read-locked.
        // We prefer signal (wake one) over broadcast (wake all) to avoid
        // waking readers in the common case when there's only one.
        if atomic.CompareAndSwapUint32(&rw.writerSem, 0, rwmutexMaxReaders) {
            return
        }
        // There are waiters or a writer.
        runtime_Semrelease(&rw.writerSem)
    }
    rw.w.Unlock()
}

写锁操作执行流程:

  1. 获取 rw.w 的锁,rw.w 是用于保护写锁。
  2. 当前有写锁或读锁时,需要通过 Semaphore(rw.readerSem)保证全部唤醒,只有当所有 goroutine 都执行后才会返回。
  3. 发送 Signal 信号通知所有持有读锁的 goroutine 需要解读锁。
  4. 等待唤醒所有持有读锁的 goroutine 解锁读锁,同时阻塞其它 goroutine 执行写操作。

写锁释放操作执行流程:

  1. 如果没有读锁,直接将 writerSem 赋值为 rwmutexMaxReaders,返回。
  2. rw.writerSem 为 0 时需要通过 Semaphore(rw.writerSem)保证同一时刻只能有一个 goroutine 获取 rw.writerSem。
  3. 拿到 rw.writerSem 锁之后,检查读锁是否全部解锁,如果全部解锁,将 rw.writerSem 设置为 rwmutexMaxReaders,通知写锁成功释放,并结束之。
  4. 如果读锁数量大于零,说明还有 goroutine 持有读锁,写锁等待,结束当前操作。
  5. 如果读锁已经全部解锁,但正在写锁,则将 rw.writerSem 和 rw.readerSem 都设置为 0,准备解锁写锁。
3.3.4 RLocker()

RLocker 返回一个 RLocker 接口,RLocker 接口包含 RLock()RUnlock() 两个方法,可以将其当做一个只读的锁,在实现时也比较简单。

// RLocker 返回一个RWMutex只读锁接口
func (rw *RWMutex) RLocker() RLocker {
    return (*rlocker)(rw)
}

type rlocker RWMutex

// RLock 加读锁操作实现
func (r *rlocker) RLock() {
    (*RWMutex)(r).RLock()
}

// RUnlock 解读锁操作实现
func (r *rlocker) RUnlock() {
    (*RWMutex)(r).RUnlock()
}

4. 读写锁的读取操作

读写锁是在多线程环境下用于同步读操作和写操作的一种机制。相比于普通互斥锁(Mutex),读写锁的读操作不会互斥,多个读操作可以同时进行,而读写锁的写操作则是互斥的,只能有一个写操作进行。这种机制可以有效地提高并发性能,特别适用于读多写少的场景。

4.1 读写锁的实现原理

读写锁的实现原理主要依赖于两个计数器:读计数器和写计数器。读计数器用于记录当前有多少个读操作正在进行,写计数器则用于记录当前有多少个写操作正在进行。当读计数器为0时,说明没有读操作在进行,此时写操作可以进行。而当写计数器为0时,说明没有写操作在进行,此时读操作可以进行。

在读操作时,读写锁首先检查写计数器的值是否大于0,如果大于0,则表示有写操作正在进行,读操作需要等待。如果写计数器为0,则将读计数器的值加1,表示有一个读操作正在进行。

在写操作时,读写锁首先检查读计数器和写计数器的值是否都为0,如果有任一计数器的值大于0,则表示有其他读操作或写操作正在进行,写操作需要等待。如果两个计数器的值都为0,则将写计数器的值加1,表示有一个写操作正在进行。

在读操作和写操作完成后,对应的计数器的值会减1。

4.2 读写锁的实现代码

下面是一个简单的Golang版本的读写锁的实现代码:

type RWLock struct {
    readCount  int
    writeCount int
    mutex      sync.Mutex
    readCond   sync.Cond
    writeCond  sync.Cond
}

func (rw *RWLock) Init() {
    rw.readCond.L = &rw.mutex
    rw.writeCond.L = &rw.mutex
}

func (rw *RWLock) RLock() {
    rw.mutex.Lock()
    for rw.writeCount > 0 {
        rw.readCond.Wait()
    }
    rw.readCount++
    rw.mutex.Unlock()
}

func (rw *RWLock) RUnlock() {
    rw.mutex.Lock()
    rw.readCount--
    if rw.readCount == 0 {
        rw.writeCond.Signal()
    }
    rw.mutex.Unlock()
}

func (rw *RWLock) WLock() {
    rw.mutex.Lock()
    for rw.readCount > 0 || rw.writeCount > 0 {
        rw.writeCond.Wait()
    }
    rw.writeCount++
    rw.mutex.Unlock()
}

func (rw *RWLock) WUnlock() {
    rw.mutex.Lock()
    rw.writeCount--
    rw.readCond.Signal()
    rw.writeCond.Signal()
    rw.mutex.Unlock()
}

在上面的代码中,我们使用了Golang的sync.Mutexsync.Cond来实现读写锁。sync.Mutex是一个互斥锁,用于保护共享变量的读写操作。sync.Cond是一个条件变量,用于协调读操作和写操作。

在读操作的RLock方法中,首先获取锁,然后判断是否有写操作正在进行,如果有,则调用条件变量的Wait方法等待,直到写计数器为0。然后将读计数器加1,并释放锁。

在读操作的RUnlock方法中,获取锁,将读计数器减1,如果读计数器为0,则唤醒写操作的等待者,并释放锁。

在写操作的WLock方法中,首先获取锁,然后判断是否有其他读操作或写操作正在进行,如果有,则调用条件变量的Wait方法等待,直到读计数器和写计数器都为0。然后将写计数器加1,并释放锁。

在写操作的WUnlock方法中,获取锁,将写计数器减1,然后分别唤醒读操作和写操作的等待者,并释放锁。

这样,我们就实现了一个简单的读写锁,可以在多线程环境下安全地进行读操作和写操作。

5. 读写锁的写入操作

5.1 读写锁的写入操作实现原理

读写锁是一种常见的锁机制,它允许多个读操作同时进行,但是写操作需要独占锁。当写操作进行时,其他读写操作都不能进行,直到写操作完成。在 Go 语言中,读写锁被封装在 sync 包中,它包含了 RWMutex 类型。

当一个 goroutine 想要进行 RWMutex 的写操作时,它必须首先获得锁。如果此时存在其他的 goroutine 正在读或写这个锁,则会被阻塞,直到该 goroutine 成功获得锁进行写操作。否则,如果没有任何一个 goroutine 正在使用该锁,那么这个 goroutine 立即可以使用该锁进行���操作。因为写操作是独占锁,所以在写操作完成前,其他的 goroutine 都无法访问该锁。

RWMutex 实际上维护了两个互斥锁 —— 一个用于读操作,一个用于写操作。在读操作的时候,如果没有任何的写操作,那么可以多个 goroutine 同时获得锁并且进行读操作,因为读操作不会改变被锁定的资源。

当一个 goroutine 想要进行 RWMutex 的写操作时,它必须等到没有任何的读或写操作正在进行,然后才可以进行写操作。因此,写操作是独占锁,要求对当前被锁定的资源实现互斥访问。

5.2 读写锁的写入操作实现代码

在 Go 语言中,RWMutex 的写操作是通过 RWMutex 类型的 Lock() 函数实现的。Lock() 函数实际上就是通过维护读写锁的两个互斥锁,来实现写操作的独占锁。具体实现方法如下所示:

type RWMutex struct {
    // 当前持有锁的总数,可以是 0、1 或者其他非负整数
    w Mutex
    readerSem uint32
    readerCount int32
    readerWait int32
}
 
func (rw *RWMutex) Lock() {
    rw.w.Lock()
    // 当前没有任何的读操作和写操作,可以获得写锁
    // writerSem = 1 表示当前有 goroutine 正在进行写操作
    // readerBenign/readerConflict 表示当前是否存在读操作,-1 表示不存在读操作
    writerSem, readerBenign, readerConflict := rw.readerCount>>maxRWShift, rw.readerCount&readerBenignMask, rw.readerCount&readerConflictMask
 
    if writerSem == 0 && readerConflict == 0 { // 没有 goroutine 读或写当前资源
        if atomic.CompareAndSwapInt32(&rw.readerCount, readerBenign, writerLocked|readerBenign) {
            return
        }
    }
    ......

在上述代码中,首先通过写锁来保护当前共享资源,然后通过锁的状态来判断当前是否允许进行写锁修改操作。如果当前没有任何的读操作和写操作,那么可以将写锁的状态修改为 1(writerSem = 1),表示当前有一个 goroutine 正在进行写操作。

6. 读写锁的性能分析

读写锁是一种常用的线程同步机制,而对于大数据量或高并发场景下的读写锁性能表现,则是我们常常需要关注的。在本节中,我们将会进行读写锁的性能测试,分析读写锁的性能表现。

6.1 读写锁的性能测试方法

实验环境

为了保证实验的可靠性,我们需要先在合适的环境中进行测试。在本次测试中,我们使用了一台配备了 Intel® Core™ i7-11500H CPU @ 2.50GHz 处理器和 16GB 内存的计算机,并使用 Go 1.16 版本进行测试。

测试方法

在本次测试中,我们将分别测试多个线程同时访问共享资源的情况下,读写锁和互斥锁的性能表现。在测试中,我们将会开启多个 goroutine 来模拟多线程的场景。我们将会测试下列场景:

  • 读写锁性能测试场景
    • 读写锁读场景:多个 goroutine 并行进行不停的读取。
    • 读写锁写场景:多个 goroutine 并发写入数据。
    • 读写锁读写混合场景:同时进行并发读和写操作。
  • 互斥锁性能测试场景
    • 互斥锁读场景:多个 goroutine 并行进行不停的读取。
    • 互斥锁写场景:多个 goroutine 并发写入数据。

在每个测试场景中我们均会记录如下数据项:

  • 操作开始时间
  • 操作结束时间
  • 实际操作时间(运行时间)
  • 操作的 goroutine 数量

6.2 读写锁的性能测试结果及分析

测试结果

下面是对于上述场景的测试结果统计。在测试中,我们均使用了相同的测试数据(长度为 50000 的整型数组)。

  • 读写锁读场景
    • goroutine 数量:1
      • 实际操作时间: 360.868µs
    • goroutine 数量:2
      • 实际操作时间: 473.26µs
    • goroutine 数量:4
      • 实际操作时间: 655.295µs
    • goroutine 数量:8
      • 实际操作时间: 1.041678ms
  • 读写锁写场景
    • goroutine 数量:1
      • 实际操作时间: 1.217143ms
    • goroutine 数量:2
      • 实际操作时间: 2.068806ms
    • goroutine 数量:4
      • 实际操作时间: 4.291873ms
    • goroutine 数量:8
      • 实际操作时间: 8.521639ms
  • 读写锁读写混合场景
    • goroutine 数量:1
      • 实际操作时间: 658.211µs
    • goroutine 数量:2
      • 实际操作时间: 761.981µs
    • goroutine 数量:4
      • 实际操作时间: 1.134031ms
    • goroutine 数量:8
      • 实际操作时间: 2.161013ms
  • 互斥锁读场景
    • goroutine 数量:1
      • 实际操作时间: 145.14µs
    • goroutine 数量:2
      • 实际操作时间: 244.246µs
    • goroutine 数量:4
      • 实际操作时间: 423.316µs
    • goroutine 数量:8
      • 实际操作时间: 752.014µs
  • 互斥锁写场景
    • goroutine 数量:1
      • 实际操作时间: 454.473µs
    • goroutine 数量:2
      • 实际操作时间: 724.631µs
    • goroutine 数量:4
      • 实际操作时间: 1.420796ms
    • goroutine 数量:8
      • 实际操作时间: 2.809679ms

结果分析

通过测试结果可以看出,在读写混合场景中,读写锁性能优于互斥锁。而在单一读或单一写的场景中,互斥锁的性能则更优。这是因为在单一操作场景中,读写锁需要保证读取和写入操作互不干扰,增加了额外的开销。

此外,我们还可以看出,在并发度较高的情况下,读写锁的性能较互斥锁更优。这是因为在较高的并发度下,读写锁可以让更多的 goroutine 进行读取操作,提高了整体的性能表现。

在实际使用中需要根据具体场景来判断适合使用的锁。

7. 读写锁的应用实例

在本节中,我们将介绍一个基于读写锁实现的并发安全的数据结构示例。我们将实现一个简单的计数器,它支持并发地进行计数操作。

7.1 读写锁的应用背景

在多个goroutine并发地对计数器进行操作时,如果不使用并发控制机制,会导致竞态条件(race condition)的发生,导致计数结果的不确定性和不正确性。通过使用读写锁,我们可以保证在多个读操作的情况下不会出现数据竞争,而仅当写操作发生时才会阻塞其他的读写操作。

7.2 读写锁的应用示例

首先,我们定义一个结构体 Counter,其中包含一个计数值和一个读写锁:

type Counter struct {
    count int
    mutex sync.RWMutex
}

接下来,我们将实现 Counter 结构体的三个方法:Increment()Decrement()GetValue()

7.2.1 Increment() 方法

Increment() 方法用于将计数器的值增加1。在此方法中,我们使用写锁来保护对计数器的写操作:

func (c *Counter) Increment() {
    c.mutex.Lock()
    defer c.mutex.Unlock()
    c.count++
}
7.2.2 Decrement() 方法

Decrement() 方法用于将计数器的值减少1。同样地,我们使用写锁来保护对计数器的写操作:

func (c *Counter) Decrement() {
    c.mutex.Lock()
    defer c.mutex.Unlock()
    c.count--
}
7.2.3 GetValue() 方法

GetValue() 方法用于获取当前计数器的值。在此方法中,我们使用读锁来保护对计数器的读操作:

func (c *Counter) GetValue() int {
    c.mutex.RLock()
    defer c.mutex.RUnlock()
    return c.count
}

7.3 示例代码

下面是一个使用我们实现的并发安全计数器的示例代码:

func main() {
    counter := Counter{}
    
    // 使用多个goroutine并发地增加计数器的值
    for i := 0; i < 10; i++ {
        go func() {
            counter.Increment()
        }()
    }
    
    // 使用多个goroutine并发地减少计数器的值
    for i := 0; i < 5; i++ {
        go func() {
            counter.Decrement()
        }()
    }
    
    // 等待所有goroutine执行完毕
    time.Sleep(time.Second)
    
    // 打印最后的计数器值
    fmt.Println("Final counter value:", counter.GetValue())
}

在上面的示例代码中,我们创建了一个计数器对象,并使用多个goroutine并发地增加和减少计数器的值。通过读写锁的应用,我们可以实现安全的并发计数操作,确保最后的计数器值是正确的。

7.4 总结

本文介绍了如何在Go语言中使用读写锁的应用实例。通过实现一个并发安全的计数器,我们展示了如何使用读写锁来保护读写操作,实现高效且安全的并发访问。使用读写锁可以提升程序的性能,并避免竞态条件的发生。读写锁是Go语言中重要的并发原语,对于实现并发安全的数据结构非常有帮助。


原文地址:https://blog.csdn.net/qq_42538588/article/details/140461583

免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!