从 lwIP-2.0.0 开始,在 opt.h
中多了一个宏开关 LWIP_TCPIP_CORE_LOCKING
,默认使能。这个宏用于启用 内核锁定
功能,使用 全局互斥锁
实现。在之前,lwIP 使用 消息机制
解决 lwIP 内核线程安全问题。消息机制易于实现,因为实现简单,更容易做到高稳定性。但使用消息机制效率低下,所以 lwIP 从 V2.0.0 开始,正式支持(并默认使用)全局互斥锁,同时仍保留消息机制。全局互斥锁方案实现复杂,但效率非常高。
本文探索全局互斥锁方案是怎么诞生并发展的。
2007 年 5 月 24 日,Simon Goldschmid (以下简称 西蒙
)提交了一个任务:task #6935(使用当前 socket/netconn API
需要解决的问题)。
西蒙提出,当前的 socket/netconn API
还存在某些设计问题需要改进,比如:
1.将消息机制改为互斥(锁定 netconns) |
move from message passing to mutual exclusion (lock netconns)
这里需要解释一下 消息机制
是什么。
在使用操作系统的情况下,lwIP 内核作为操作系统的一个任务( tchip_thread
)运行,内核线程
和 应用程序线程
间的通讯是通过消息机制来完成的。
在当时,距离发布 lwIP-1.3.0 版本还有 10 个月,在这个版本中,如果 应用程序线程
使用 sendto
函数发送 UDP 数据,这是一个复杂的过程。实际上应用程序线程无权发送数据,它会先构造一个称为 TCPIP_MSG_API
的消息,连带发送的数据信息,投递给 内核线程
,由内核线程进行实际的数据发送:
从图中可以看出,一个普通的数据发送,需要两个线程协同、进行两次任务切换才能完成。多年以来,lwIP 对外声称都是”为了获得良好的性能,你应该使用 raw API
“。这就是原因!因为线程间通讯低效造成的。
为什么要让内核线程完成实际的发送?直接在 应用程序线程
中发送不可以吗?
不可以!
这涉及到多年以来 lwIP 对外声称的另一个注意事项:“lwIP 不是线程安全的”!!!
从 lwip 诞生以来,直到最近发布的 lwIP-2.1.x,以及可以看到的将来,lwIP 都不是线程安全的。
所以 lwIP 采用了一个迂回的策略,用来满足多线程编程:使用 并发设计
中的 队列模式
。
举一个很好理解的例子。
如果有两个任务使用同一个 串口
打印调试信息,任务 A 打印:
printf("Task A: ABCDEFG\n");
任务 B 打印:
printf("Task B: 1234567\n");
任务A正在打印的时候,可能被任务B打断,这时任务A的打印信息就会显得不完整,任务 A 打印的信息被任务 B 打印的信息分成两部分:
Task A: ABTask B: 1234567
CDEFG
出现这个问题的原因是有多个任务同时使用一个资源,那么让任务不去同时访问资源,更进一步,干脆让任务不访问资源不就解决了吗。队列模式
就是这个思路,即所有任务都不直接使用串口资源,而是将要打印的信息放到一个队列中,串口发送也独立出一个任务,该任务从队列中取出信息,打印信息。完美解决大家争资源的问题。
lwIP 处理 线程不安全
也是用的这个思路:创建一个邮箱队列,投递消息相当于入队,内核线程
从邮箱队列中取出消息,根据消息的指示做出相应的动作,所有 应用程序线程
都不访问 lwIP 内核,也就避免了线程不安全的问题。
除了一个问题外,效率低。
西蒙萌生出了改变这一现状的想法:
- 对于发送,我们可以锁定整个核心,而不是将消息传递给另一个线程。这样
TX
可以优先处理。锁定互斥锁的运行时间不应比消息传递长多少。 - 对于接收,同样可以锁定整个核心,这样
tchip_thread
只需要处理RX
和定时器
。
随着讨论的进行,大家觉得使用全局互斥锁是个可行的方案。
Frédéric Bernon (以下简称 佛雷德里克
)根据这个思路修改了代码,并进行了测试。他的测试代码是用 sendto
函数发送 1458 字节的数据(UDP 连接),测试结果如下:
Sequential API: lwip_sendto() | raw API: udp_sendto | |
---|---|---|
使用消息机制 | 204 us | 74 us |
使用全局互斥量 | 67 us | 74 us |
可以看出,使用全局互斥量后:
- lwip_sendto 函数用时减少了 3 倍
- 与 udp_sendto 函数执行时间相比,仅慢了 3us 。
可以说效果拔群。 佛雷德里克
是怎么做到的呢?原因在于他使用了全局互斥量让发送数据成了 原子操作
,从而避免了线程间的切换,实现了在 应用程序线程
中调用 udp_sendto
函数直接完成数据的发送:
由于少了消息传递和任务切换的开销,速度自然也就快了起来。
这种使用互斥量保护共享资源的操作,使用了 并发设计
中的 守卫调用模式
。如果多个线程以某种方式同时调用相互干扰的服务集合,可以使用守卫调用模式使得多个线程串行访问服务。最常见的实现方式是使用互斥量给共享资源加锁。
继续用上面的例子,有两个任务使用同一个 串口
打印调试信息,则可能出现打印乱套的问题。如果换一种方式,任务 A 在使用串口前,先把串口锁定,一旦锁定,除非任务 A 主动解锁,否则其它任务都不能使用串口。这样就能避免任务 A 正在使用串口打印信息时,被任务 B 打断的现象了。
2007 年 6 月 9 日, 佛雷德里克
将这个更改推送到了 lwIP 代码仓库,这是历史上第一次,宏 LWIP_TCPIP_CORE_LOCKING
出现在 lwIP 的源码中,默认设置为 0 ,即不启用该新功能。
但此时的代码还是实验性质的,仅供 lwIP 开发者测试用,在这个宏开关的注释中,有着明显的提示:
/* EXPERIMENTAL, Don't use it if you're not an active lwIP project member */
实验性质,如果你不是 lwIP 项目的开发人员,请不要使用它
之后,使用互斥量 锁定内核
的操作方式不断完善:
- 2007 年 7 月底,API 消息(
TCPIP_MSG_API
)已经几乎完全可以使用锁定内核的方式替换掉,这包括连接、发送等操作 - 2010 年 2 月份,
西蒙
第一次引入宏LWIP_TCPIP_CORE_LOCKING_INPUT
,底层数据包输入消息(TCPIP_MSG_INPKT
)也可以选择使用锁定内核的方式了。当然,这依旧是实验性质的。 - 2016 年 3 月 17,终于,
LWIP_TCPIP_CORE_LOCKING
和LWIP_TCPIP_CORE_LOCKING_INPUT
不再是实验性质的,它们正式向用户开放了!从第一次讨论“将消息机制改为互斥”,到最终推出这个功能,用了将近 10 年! - 2016 年 7 月 1,宏
LWIP_TCPIP_CORE_LOCKING
的默认值修改为 1 ,这意味着解决 “lwIP 不是线程安全的”的方案, lwIP 开发者更推荐使用互斥锁。 - 2016 年 11 月 10, lwIP-2.0.0 发布,在这个版本中,我们第一次可以放心使用互斥锁方案。
时代变了。“为了获得良好的性能,你应该使用 raw API
” 这一印象,从 lwIP-2.0.0 开始,要改写了!从此,使用 Sequential
或 socket
API 也可以达到接近 raw API
的性能!
有关操作系统移植层的信息,可以参考我的这篇博文。
读后有收获,资助博主养娃 - 千金难买知识,但可以买好多奶粉 (〃‘▽’〃)