一、先搞懂两个核心概念:Lua协程和C线程
很多刚接触混合开发的朋友,容易把Lua协程和C线程搞混,其实这俩就像“单人多任务切换”和“多人同时干活”的区别。先给大家用大白话讲清楚,不然后面说竞态条件就像听天书。
1.1 Lua协程是什么
Lua协程就像你手机上的一个APP,一次只能干一件事,但它可以随时暂停、切换到另一个协程,再接着回来干。比如你写个协程A,先下载文件,下到一半暂停,切去协程B处理数据,处理完再切回A继续下载——全程只有一个“核心”在跑,只是切换速度快,看起来像同时干。
举个简单的Lua协程示例,大家一看就懂:
-- 技术栈:Lua 5.4
-- 协程A:模拟下载文件
local coroA = coroutine.create(function()
print("协程A开始下载文件")
-- 模拟下载到一半暂停
coroutine.yield("暂停下载,切去协程B")
print("协程A继续下载完成")
end)
-- 协程B:模拟处理数据
local coroB = coroutine.create(function()
print("协程B开始处理数据")
print("协程B处理数据完成")
end)
-- 先切协程A
local status, msg = coroutine.resume(coroA)
print(msg) -- 输出暂停提示
-- 再切协程B
coroutine.resume(coroB)
-- 最后切回协程A
coroutine.resume(coroA)
这个示例里,两个协程是顺序切换的,没有同时干活,所以不会有冲突。
1.2 C线程是什么
C线程就像你家里的多台电脑,每台电脑都能同时干活。比如你开了两个C线程,线程1下载文件,线程2同时处理数据,俩线程各用各的“核心”,真的是同时运行。
同样给个简单的C线程示例:
// 技术栈:C11(使用标准库线程函数)
#include <stdio.h>
#include <pthread.h> // POSIX线程库,Linux/macOS可用,Windows需对应适配
#include <unistd.h> // 用于sleep模拟耗时操作
// 线程1:模拟下载文件
void* thread1(void* arg) {
printf("线程1开始下载文件\n");
sleep(2); // 模拟下载耗时2秒
printf("线程1下载完成\n");
return NULL;
}
// 线程2:模拟处理数据
void* thread2(void* arg) {
printf("线程2开始处理数据\n");
sleep(1); // 模拟处理耗时1秒
printf("线程2处理完成\n");
return NULL;
}
int main() {
pthread_t t1, t2;
// 创建两个线程
pthread_create(&t1, NULL, thread1, NULL);
pthread_create(&t2, NULL, thread2, NULL);
// 等待两个线程结束
pthread_join(t1, NULL);
pthread_join(t2, NULL);
return 0;
}
这个示例里,线程1和线程2真的是同时跑的,线程2处理完的时候,线程1还在下载。
二、为什么混合用会出竞态条件?
当你把上面两种“干活模式”混在一起,比如用Lua写业务逻辑,用C写耗时的底层操作(比如网络、IO),就容易出问题。核心原因是:C线程是真·同时干活,而Lua协程是单线程切换,两者共用同一份数据时,就会出现“抢数据”的情况,这就是竞态条件。
2.1 举个竞态条件的真实场景
比如你做一个聊天工具,用Lua协程写聊天消息的发送逻辑,用C线程写网络消息的接收逻辑,两者共用一个消息计数变量msg_count——Lua每发一条消息,给msg_count加1;C线程每收一条消息,也给msg_count加1。
先看出问题的示例:
-- 技术栈:Lua 5.4 + CFFI(Lua调用C的工具,用于混合开发)
-- Lua侧的消息计数变量
msg_count = 0
-- 模拟Lua协程发送消息
function send_msg()
print("Lua协程开始发送消息,当前计数:"..msg_count)
-- 模拟发送耗时,暂停协程,让C线程有机会抢资源
coroutine.yield()
-- 发送完成后计数加1
msg_count = msg_count + 1
print("Lua协程发送完成,当前计数:"..msg_count)
end
-- 注册C侧的接收消息函数,C线程会调用这个函数
function c_receive_msg()
print("C线程开始接收消息,当前计数:"..msg_count)
-- 模拟接收耗时,这里C线程不会暂停,会直接执行
msg_count = msg_count + 1
print("C线程接收完成,当前计数:"..msg_count)
end
-- 启动Lua协程
local coro = coroutine.create(send_msg)
coroutine.resume(coro) -- 执行到yield暂停
-- 模拟C线程调用接收函数(实际开发中C线程是独立运行的)
c_receive_msg()
-- 继续执行Lua协程
coroutine.resume(coro)
运行这段代码,你会发现什么?原本应该先加1(Lua发送)再加1(C接收),结果变成了C接收先加1,Lua发送再加1——因为Lua协程暂停的时候,C线程抢了数据,改了msg_count,等Lua协程回来,就用了错误的旧值,导致计数不对。
这就是竞态条件:两个执行流(一个Lua协程,一个C线程)同时修改同一份数据,最终结果不确定,完全看谁抢得快。
2.2 竞态条件的危害
这种问题很难复现,因为它跟系统的调度速度、网络延迟、CPU负载都有关系——可能测试的时候跑100次都没问题,上线后用户用的时候偶尔出一次错,查都查不到。比如上面的聊天工具,可能偶尔少算一条消息,导致用户看不到自己发的消息,或者收到的消息重复计数,体验极差。
三、混合开发的同步原语怎么选?
要解决竞态条件,核心是让两个执行流“排队”干活,不能同时改同一份数据。常见的同步原语有锁、信号量、条件变量,下面结合Lua和C混合的场景,告诉大家怎么选。
3.1 先搞懂几个常用同步原语的大白话解释
- 锁:就像卫生间的门,一个人进去就锁门,其他人只能等,出来再开门。同一时间只能有一个人用卫生间,非常适合“不能同时改数据”的场景。
- 信号量:就像餐厅的取号机,有几个空位就发几个号,超过的人只能等。适合“最多允许N个人同时干活”的场景。
- 条件变量:就像超市的叫号器,服务员(某个执行流)准备好服务了,就叫号,顾客(其他执行流)听到号才过来,不然就等着。适合“要等某个条件满足才干活”的场景。
3.2 锁:最常用的选择,适合大多数场景
如果你的场景是“同一时间只能有一个执行流改数据”,锁是首选。因为锁的逻辑最简单,不容易出错,而且Lua和C都能支持。
举个加锁解决上面竞态问题的示例,这里用Lua的mutex锁(实际开发中如果涉及C和Lua跨层,建议用C实现的锁,因为Lua协程的锁是单线程内的,C线程识别不了,这里先演示锁的逻辑,后面讲跨层锁的实现):
-- 技术栈:Lua 5.4 + LuaMutex(Lua的锁扩展,用于协程间同步)
local mutex = require("luamutex") -- 引入锁扩展
local lock = mutex.new() -- 创建一个锁
-- Lua侧的消息计数变量
msg_count = 0
-- 模拟Lua协程发送消息
function send_msg()
-- 加锁:如果锁被占用,就等
lock:lock()
print("Lua协程开始发送消息,当前计数:"..msg_count)
-- 模拟发送耗时,暂停协程,此时锁还没释放,C线程抢不到
coroutine.yield()
-- 发送完成后计数加1
msg_count = msg_count + 1
print("Lua协程发送完成,当前计数:"..msg_count)
-- 解锁:让其他人可以用
lock:unlock()
end
-- 注册C侧的接收消息函数
function c_receive_msg()
-- 加锁:如果锁被Lua占用,就等
lock:lock()
print("C线程开始接收消息,当前计数:"..msg_count)
-- 模拟接收耗时
msg_count = msg_count + 1
print("C线程接收完成,当前计数:"..msg_count)
-- 解锁
lock:unlock()
end
-- 启动Lua协程
local coro = coroutine.create(send_msg)
coroutine.resume(coro) -- 执行到yield暂停,锁被Lua占用
-- 模拟C线程调用接收函数,此时锁被Lua占用,会等
c_receive_msg()
-- 继续执行Lua协程
coroutine.resume(coro)
加锁之后,Lua协程在发送消息时,锁是占用的,C线程要改数据只能等Lua协程解锁,这样就不会同时改数据,计数就对了。
3.3 信号量:适合限制并发数的场景
如果你的场景是“最多允许N个执行流同时干活”,比如一个下载工具,最多同时下载3个文件,就适合用信号量。
举个信号量的示例:
-- 技术栈:Lua 5.4 + LuaSemaphore(Lua的信号量扩展)
local semaphore = require("luasemaphore")
local sem = semaphore.new(3) -- 最多允许3个执行流同时干活
-- 模拟Lua协程下载文件
function download_file(file_id)
-- 申请信号量:如果当前已经有3个在干活,就等
sem:wait()
print("Lua协程开始下载文件"..file_id)
-- 模拟下载耗时
coroutine.yield()
print("Lua协程下载文件"..file_id)
-- 释放信号量:让其他人可以干活
sem:post()
end
-- 模拟C线程上传文件
function upload_file(file_id)
-- 申请信号量
sem:wait()
print("C线程开始上传文件"..file_id)
-- 模拟上传耗时
sem:post()
print("C线程上传文件"..file_id)
end
-- 启动5个Lua协程和5个C线程,最多同时3个干活
for i=1,5 do
local coro = coroutine.create(download_file)
coroutine.resume(coro, i)
end
for i=1,5 do
upload_file(i)
end
这个示例里,不管多少个协程和线程,最多只有3个能同时干活,不会出现同时太多任务抢资源的情况。
3.4 条件变量:适合“等条件满足才干活”的场景
如果你的场景是“要等某个条件满足才干活”,比如Lua协程要等C线程下载完文件才处理,就适合用条件变量。
举个条件变量的示例:
-- 技术栈:Lua 5.4 + LuaCondVar(Lua的条件变量扩展)
local cond = require("luacondvar")
local mutex = require("luamutex")
local lock = mutex.new()
local cond_var = cond.new()
-- 标记文件是否下载完成
local file_downloaded = false
-- C线程:下载文件
function c_download_file()
lock:lock()
print("C线程开始下载文件")
-- 模拟下载耗时
file_downloaded = true
print("C线程下载完成")
-- 通知所有等条件的执行流
cond_var:notify_all()
lock:unlock()
end
-- Lua协程:处理文件
function lua_process_file()
lock:lock()
-- 等文件下载完成的条件,没完成就等
while not file_downloaded do
cond_var:wait(lock) -- 等的时候会自动解锁,被通知后会自动加锁
end
print("Lua协程开始处理文件")
lock:unlock()
end
-- 启动Lua协程,先处理,此时没下载完成,会等
local coro = coroutine.create(lua_process_file)
coroutine.resume(coro)
-- 启动C线程下载文件
c_download_file()
这个示例里,Lua协程要等C线程下载完才处理,不会出现“文件没下完就处理”的错误。
3.5 跨层同步的注意事项:Lua和C的锁要统一
这里要特别提醒:如果你的锁是在Lua层实现的,C线程识别不了,因为Lua的锁是基于协程切换的,C线程是独立的,所以跨层同步一定要用C实现的锁,比如用C的pthread_mutex,然后通过Lua的CFFI调用,这样Lua协程和C线程都能识别这个锁。
举个跨层锁的示例:
// 技术栈:C11 + Lua 5.4 CFFI
#include <pthread.h>
#include <lua.h>
#include <lauxlib.h>
// 全局锁,用于Lua和C跨层同步
pthread_mutex_t global_mutex;
// 初始化锁的函数,Lua调用
static int l_init_mutex(lua_State *L) {
pthread_mutex_init(&global_mutex, NULL);
return 0;
}
// 加锁函数,Lua调用
static int l_lock(lua_State *L) {
pthread_mutex_lock(&global_mutex);
return 0;
}
// 解锁函数,Lua调用
static int l_unlock(lua_State *L) {
pthread_mutex_unlock(&global_mutex);
return 0;
}
// Lua注册C函数的入口
int luaopen_cmutex(lua_State *L) {
luaL_Reg reg[] = {
{"init", l_init_mutex},
{"lock", l_lock},
{"unlock", l_unlock},
{NULL, NULL}
};
luaL_newlib(L, reg);
return 1;
}
然后Lua侧就可以调用这个C实现的锁:
-- 技术栈:Lua 5.4 + CFFI
local cmutex = require("cmutex")
cmutex.init() -- 初始化锁
-- Lua协程和C线程都用这个锁,就能实现跨层同步了
四、应用场景、优缺点和注意事项
4.1 应用场景
Lua协程和C线程混合的场景,通常是需要兼顾开发效率和性能的场景:
- 游戏开发:用Lua写游戏逻辑(开发快),用C写底层渲染、网络(性能高)。
- 服务端开发:用Lua写业务逻辑(迭代快),用C写IO、数据库操作(性能高)。
- 嵌入式开发:用Lua写上层控制逻辑,用C写底层硬件操作。
4.2 不同同步原语的优缺点
| 同步原语 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 锁 | 逻辑简单,容易实现,适合大多数场景 | 用不好会出现死锁(比如两个执行流互相等对方的锁) | 同一时间只能一个执行流改数据 |
| 信号量 | 可以限制并发数,适合资源有限的场景 | 逻辑比锁复杂,容易出现信号量泄露(比如没释放信号量) | 最多允许N个执行流同时干活 |
| 条件变量 | 适合等条件满足才干活的场景 | 必须配合锁使用,逻辑复杂,容易出现虚假唤醒(比如被通知但条件没满足) | 要等某个条件满足才干活 |
4.3 注意事项
- 尽量减少锁的范围:锁的范围越小,性能越好,比如只在改数据的地方加锁,不要在整个函数加锁。
- 避免死锁:加锁的顺序要一致,比如A函数先加锁1再加锁2,B函数也要先加锁1再加锁2,不能反过来。
- 尽量用成熟的扩展:不要自己写锁或信号量,尽量用Lua官方或知名的扩展,比如Lua的luamutex、luasemaphore,避免自己写的有bug。
- 测试要充分:竞态条件很难复现,要多测不同的场景,比如高并发、高负载的场景,确保没有问题。
五、文章总结
Lua协程和C线程混合使用,因为执行模式不同,容易出现竞态条件,解决的核心是用同步原语让执行流排队干活。锁是最常用的选择,适合大多数场景;信号量适合限制并发数的场景;条件变量适合等条件满足才干活的场景。跨层同步一定要用C实现的锁,避免Lua和C识别不了的问题。同时要注意锁的范围、死锁、成熟扩展的使用,确保程序的稳定性和性能。
Comments