个人对GCD信号量的一些误解...

3,792 阅读6分钟

以前认为信号量的初始值就是线程的最大并发数,不可更改的,其实并不然。

平时开发一般都使用GCD信号量(DispatchSemaphore)来解决线程安全问题:当多个线程访问同一块资源时,很容易引发数据错乱和数据安全问题。

经典的多线程安全隐患示例 - 卖票

  • 使用信号量之前:
var ticketTotal = 15
let group: DispatchGroup = DispatchGroup()
    
// 卖票操作
func __saleTicket(_ saleCount: Int) {
    DispatchQueue.global().async(group: group, qos: .default, flags: []) {
        for _ in 0..<saleCount {
            // 加个延时可以大概率让多条线程同时进行到这一步
            sleep(1) 
            // 卖一张
            self.ticketTotal -= 1 
        }
    }
}

// 开始卖票
func startSaleTicket() {
    print("\(Date()) 一开始总共有\(ticketTotal)张")

    print("\(Date()) 第一次卖5张票")
    __saleTicket(5)

    print("\(Date()) 第二次卖5张票")
    __saleTicket(5)

    print("\(Date()) 第三次卖5张票")
    __saleTicket(5)

    group.notify(queue: .main) {
        print("\(Date()) 理论上全部卖完了,实际上剩\(self.ticketTotal)张")
    }
}

打印结果:

明显结果是错的,15张卖了15次却还剩4张,这是多线程操作引发的数据错乱问题。

  • 使用信号量对卖票的操作进行加解🔐:
func __saleTicket(_ saleCount: Int) {
    DispatchQueue.global().async(group: group, qos: .default, flags: []) {
        for _ in 0..<saleCount {
            // 加个延时可以大概率让多条线程同时进行到这一步
            sleep(1) 
            // 卖一张
            self.semaphore.wait() // 加🔐
            self.ticketTotal -= 1 
            self.semaphore.signal() // 解🔐
        }
    }
}

打印结果:

结果正确,多线程操作使用信号量就可以实现线程同步以保证数据安全了。

对信号量的误解

以前认为信号量的初始值是指线程的最大并发数,不可更改的,直到看到其他文章介绍的一个信号量用法:

let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)

func semaphoreTest() {
    DispatchQueue.global().async {

        DispatchQueue.main.async {
            // 从主队列中获取一些信息
            ...
            // 发送信号
            self.semaphore.signal()     
        }

        // 开始等待
        self.semaphore.wait() 
        // 等待结束,线程继续
    }
}

看到这个用法就开始觉得奇怪了,明明初始化为0,不就是线程最大并发数为0吗?不就是不能有线程可以工作吗?按道理应该会一直阻塞住这个子线程才对,那这种用法有什么意义呢?

对信号量的重新认识

众所周知,semaphore.wait()是减1操作,不过这个减1操作的前提是信号量是否大于0:

  1. 如果大于0,线程可以继续往下跑,然后紧接在semaphore.wait()这句过后,才会真正对信号量减1;
  2. 如果等于0,就会让线程休眠,加入到一个都等待这个信号的线程队列当中,当信号量大于0时,就会唤醒这个等待队列中靠前的线程,继续线程后面代码且对信号量减1,也就确保了信号量大于0才减1,所以不存在信号量小于0的情况(除非在初始化时设置为负数,不过这样做的话当使用时程序就会崩溃)。

semaphore.signal()是对信号量的加1操作,后来经过测试发现,通过semaphore.signal()可以任意添加信号量,所以初始化的信号量并非不可更改的,是可以随意更改的

  • 验证的🌰:
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0)

func semaphoreTest() {
    let result1 = semaphore.signal() // 0 + 1 = 1
    print("\(Date()) \(Thread.current) signal result: \(result1)")
    
    let result2 = semaphore.signal() // 1 + 1 = 2
    print("\(Date()) \(Thread.current) signal result: \(result2)")
    
    let result3 = semaphore.signal() // 2 + 1 = 3
    print("\(Date()) \(Thread.current) signal result: \(result3)")

    semaphore.wait() // 3 - 1 = 2
    print("\(Date()) \(Thread.current) hello_1")

    semaphore.wait() // 2 - 1 = 1
    print("\(Date()) \(Thread.current) hello_2")

    semaphore.wait() // 1 - 1 = 0
    print("\(Date()) \(Thread.current) hello_3")

    DispatchQueue.global().asyncAfter(deadline: .now() + 3) {
        print("\(Date()) \(Thread.current) 信号量+1")
        
        let result = self.semaphore.signal() // 0 + 1 = 1
        print("\(Date()) \(Thread.current) signal result: \(result)")
        
        /**
         * PS: `signal()`会返回一个结果,文档解释为:
         * `This function returns non-zero if a thread is woken. Otherwise, zero is returned.`
         * 意思是:如果线程被唤醒,则此函数返回非零,否则,返回零。
         * 这里`signal()`执行后会有一条线程被唤醒,所以返回1,而开头那三次`signal()`返回的都是0,说明那会没有线程需要被唤醒,不过信号量的确是有+1的。
         */
    }

    semaphore.wait() //
    print("\(Date()) \(Thread.current) hello_4") // 1 - 1 = 0
}

打印结果: 2491684163268_.pic.jpg

可以看出,即便信号量初始为0,也可以手动添加信号量,所以前3句能马上打印;而到了最后1句前由于没有信号了,线程进入休眠无法执行,然后3秒后在另一条线程添加了信号量,这条线程才被唤醒去打印最后一句。

另外,signal()会返回一个结果,文档解释为:This function returns non-zero if a thread is woken. Otherwise, zero is returned. 意思是:如果线程被唤醒,则此函数返回非零,否则,返回零。 所以开头那3次signal()返回的都是0,说明那会没有线程需要被唤醒,而最后一次signal()返回1说明有一条线程被唤醒了 ----- 打印最后一句。

证明了信号量是可以自己维护的,只是“看不见”(没有API获取)。

  • 伪代码解释前面介绍的用法:
// 初始化信号量为0,假设 semaphoreCount 是代表信号量的一个数字
let semaphore: DispatchSemaphore = DispatchSemaphore(value: 0) // semaphoreCount = 0

func semaphoreTest() {
    DispatchQueue.global().async {
        //【1】开始执行任务1

        DispatchQueue.main.async {
            //【4】开始执行任务2
            ...
            //【5】任务2结束,信号量加1,发送信号,唤醒等待靠前的线程
            self.semaphore.signal() // semaphoreCount + 1 = 1    
        }

        //【2】任务1需要等待任务2执行完才继续,判断有无信号量
        self.semaphore.wait() //【3】判断信号量,发现 semaphoreCount == 0,这里”卡住“(休眠)
        
        //【6】能来到这里,说明信号量至少为1,唤醒了这条线程,同时对信号量减1
        // semaphoreCount - 1 = 0

        // 减1后如果等于0,那么其他还在等这个信号量的线程只能继续等,而这条线程会继续往下执行。 
        //【7】任务1继续
    }
}

总结

  1. GCD信号量的初始值的确是线程的最大并发数,不过这个并发数不是不能修改的,可以通过semaphore.signal()任意添加的,相当于是有个隐藏的semaphoreCount来控制能有多少条线程能同时工作;
  2. 这个semaphoreCount至少要有 1 才可以执行代码,只要是 0,semaphore.wait()就会让线程休眠等着直到semaphoreCount大于 0 才唤醒;
  3. 由于没有API获取这个semaphoreCount,所以一定要注意:用过多少次semaphore.wait()就记得也要用多少次semaphore.signal(),保证使用配对,不然线程会永远休眠。

知道这些后,以后GCD信号量除了可以加解🔐外,也可以做到让当前线程等待别的线程了,也就是说可以控制线程的执行时机喔~

GCD其它一些需要自己维护配对次数的函数

这些函数也是没有相应API获取次数,需要自己维护:

  • 队列组:group.enter()group.leave()
  • 定时器:timer.resume()timer.suspend()
    • PS:不要在暂停suspend状态下执行cancel(),否则会崩溃,所以记得在cancel()前确定 timer 是在运行resume状态下。