使用kotlin 协程调用java synchronized 同步方法会出现安全问题吗?

304 阅读5分钟

先说结论:不会有安全性的问题。原因是kotlin协程在执行synchronized代码块时不会让出cpu(只有执行suspend函数时才会让出cpu被调度),会一直执行下去。另外kotlin的synchronized关键字无法修饰suspend函数,协程在执行synchronized修饰的kotlin函数时也不会被调度。所以不会产生安全性的问题。
但是!你不应该在协程环境中使用synchronized锁,synchronized 会导致线程阻塞,而协程的核心价值在于非阻塞挂起。

起因是开发中遇到了这样的问题:app上层代码使用了kotlin,而依赖的库是用java写的。java代码中使用了synchronized关键字控制线程同步。如果同一个线程中的多个协程同时调用java的同步方法会产生安全性的问题吗?

网上的说法是synchronized关键字在单线程上的多个协程之间仍然有效。这个结论与我最初的想法相反,synchronized关键字控制线程之间的并发,如果多个协程运行在同一个线程之中,synchronized关键字应该无法控制才对。

于是我做了下面的一些实验:

1. 多协程并发访问synchronized方法

public class SyncTest {
    private static final String TAG = "SyncTest";
    void doSomethingSync(int index) {
        synchronized (this) {
            Log.d(TAG, "doSomething: start index ="+index+" ThreadName = " + Thread.currentThread().getName() + "");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            Log.d(TAG, "doSomething: end index ="+index+" ThreadName = " + Thread.currentThread().getName() + "");
        }
    }
}
@OptIn(ExperimentalCoroutinesApi::class, DelicateCoroutinesApi::class)
fun test1() {
    val dispatcher = newSingleThreadContext("syncTestThread")
    val syncTest = SyncTest()

    repeat(10) {index->
        CoroutineScope(dispatcher).launch {
            syncTest.doSomethingSync(index)
        }
    }
}

实验结果:

doSomething: start index =0 ThreadName = syncTestThread
doSomething: end index =0 ThreadName = syncTestThread
doSomething: start index =1 ThreadName = syncTestThread
doSomething: end index =1 ThreadName = syncTestThread
doSomething: start index =2 ThreadName = syncTestThread
doSomething: end index =2 ThreadName = syncTestThread
doSomething: start index =3 ThreadName = syncTestThread
doSomething: end index =3 ThreadName = syncTestThread
doSomething: start index =4 ThreadName = syncTestThread
doSomething: end index =4 ThreadName = syncTestThread
doSomething: start index =5 ThreadName = syncTestThread
doSomething: end index =5 ThreadName = syncTestThread
doSomething: start index =6 ThreadName = syncTestThread
doSomething: end index =6 ThreadName = syncTestThread
doSomething: start index =7 ThreadName = syncTestThread
doSomething: end index =7 ThreadName = syncTestThread
doSomething: start index =8 ThreadName = syncTestThread
doSomething: end index =8 ThreadName = syncTestThread
doSomething: start index =9 ThreadName = syncTestThread
doSomething: end index =9 ThreadName = syncTestThread

可以看到10个协程依次执行了doSomethingSync方法,不存在方法执行的过程中有其他协程进入同步方法块的情况。

2. 多协程并发访问非synchronized方法

void doSomething(int index) {
    synchronized (this) {
        Log.d(TAG, "doSomething: start index ="+index+" ThreadName = " + Thread.currentThread().getName() + "");
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        Log.d(TAG, "doSomething: end index ="+index+" ThreadName = " + Thread.currentThread().getName() + "");
    }
}
fun test2() {
    val syncTest = SyncTest()
    repeat(10) {index->
        syncTest.doSomething(index)
    }
}

实验结果

doSomething: start index =0 ThreadName = syncTestThread
doSomething: end index =0 ThreadName = syncTestThread
doSomething: start index =1 ThreadName = syncTestThread
doSomething: end index =1 ThreadName = syncTestThread
doSomething: start index =2 ThreadName = syncTestThread
doSomething: end index =2 ThreadName = syncTestThread
doSomething: start index =3 ThreadName = syncTestThread
doSomething: end index =3 ThreadName = syncTestThread
doSomething: start index =4 ThreadName = syncTestThread
doSomething: end index =4 ThreadName = syncTestThread
doSomething: start index =5 ThreadName = syncTestThread
doSomething: end index =5 ThreadName = syncTestThread
doSomething: start index =6 ThreadName = syncTestThread
doSomething: end index =6 ThreadName = syncTestThread
doSomething: start index =7 ThreadName = syncTestThread
doSomething: end index =7 ThreadName = syncTestThread
doSomething: start index =8 ThreadName = syncTestThread
doSomething: end index =8 ThreadName = syncTestThread
doSomething: start index =9 ThreadName = syncTestThread
doSomething: end index =9 ThreadName = syncTestThread

虽然没有synchronized关键字,10个协程依然依次执行了doSomething方法,可见Thread.sleep操作并不会使当前协程被调度。(因为协程不会被强制中断,只有在遇到suspend函数时才会主动让出执行权

那如果使用synchronized关键字的是kotlin代码,synchronized修饰的方法块中调用了suspend函数会怎么样?

3. 在kotlin中使用Synchronized关键字修饰suspend方法

我写了下面的代码准备进行测试,结果编译器报错。

class KotlinSyncTest()  {

    @Synchronized//这里会报错:@Synchronized annotation is not applicable to suspend functions and lambdas
    suspend fun doSomething(index: Int) {
        repeat(10) {
            println("doSomething $index")
            delay(100)
        }
    }

}

在@Synchronized这行报错:@Synchronized annotation is not applicable to suspend functions and lambdas。意思是:Synchronized注释不适用于suspend函数和 lambda表达式。kotlin从语法上避免了Synchronized修饰的函数在被协程执行时由于协程调度而引起的同时访同步代码块的问安全性问题。

最后具体说一下为什么不应该在协程环境中使用synchronized

1. 线程饥饿与性能瓶颈

协程是运行在有限的线程池(如 Dispatchers.DefaultDispatchers.IO)之上的。

  • 在普通多线程中: 如果一个线程被 synchronized 锁住,操作系统会切换到其他工作线程。
  • 在协程环境中: 协程是协作式的。如果你在协程里用了 synchronized,它会直接霸占当前的执行线程,直到锁释放。如果该线程池中的所有线程都被锁住了,整个应用的协程都会陷入瘫痪,即使它们本身并不竞争这把锁。

2. 无法跨越“挂起点”

这是最核心的技术限制。synchronized线程级的,而协程是任务级的。

当一个协程在 synchronized 块中遇到 suspend 函数(挂起点)时,它会释放执行权限并挂起。 但是,锁并不会随着挂起而释放。它依然被绑定在当前执行的线程上。 当该协程恢复执行时,如果它被分配到了另一个线程(这在协程调度中很常见),它会发现自己无法解锁,或者试图去解一把根本不属于当前线程的锁,从而引发错误或死锁。

3. 替代方案:Mutex

为了解决这个问题,Kotlin 官方提供了 kotlinx.coroutines.sync.Mutex

特性synchronizedMutex
底层机制阻塞当前线程挂起当前协程
开销高(涉及内核态切换)低(轻量级)
配合挂起函数不支持(会导致死锁或逻辑错误)完美支持