最近在用Lua实现Websocket协议时,碰到了一个直击我的思维惯性的弱点的Bug。代码大约如下(实际实现较为复杂,比如还支持wss协议,因此定位到问题也着实花费了一些功夫,毕竟GC的执行是异步的.):
--websocket.lua
local M = {}
local mt = {
__index = M,
__gc = function(sock)
close_via_c_layer(sock[1])
end}
function M:connect(url)
local ip,port = parse from url
local fd = connect_via_c_layer(ip,port);
local sock = setmetatable({fd}, mt)
return sock
end
....
return M
--foo.lua
local ws = require "websocket"
local sock = ws:connect("ws://127.0.0.1")
print(sock)
--main.lua
require "foo"
while true do
collectgarbage("step", 1024)
sleep for a while
end
起初,我发现foo.lua中建立的链接会被莫名关闭,各种排查websocket的实现。最后才发现竟然是sock对象的__gc函数被触发了。
查到的问题后,我足足想了有5分钟才明白过来为什么sock会被GC掉。
因为潜意识中,foo.lua类似于下面C代码,其中sock变量是与整个C代码的生命周期一致的。而在C语言中,代码是不会被回收的。因此sock是作用域有限的全局变量。
#include "websocket"
static websocket sock;
void exec()
{
sock = websocket::connect("ws://127.0.0.1:8001");
print(sock);
}
那为什么在Lua中sock变量会被GC掉,就要从Lua的基本规则说起:
在Lua中,一共有8种基本类型: nil、boolean、number、string、function、userdata、 thread 和 table。
其中’string,function,userdata,thread,table’等需要额外分配内存的数据类型均受Lua中的GC管理。
而require "foo" 的本质工作(如果你没有修改packaeg.preload的话)是在合适的路径找到foo.lua,并将其编译为一个chunk(一个拥有不定参数的匿名函数),然后执行这个chunk来获取返回值,并将返回值赋给package.loaded["foo"]。
在这个chunk被执行之后,整个LuaVM再无一处引用着此chunk. 因而此chunk可以被GC掉,而顺带着,被chunk引用的sock变量也一并被GC掉(因为sock变量仅被此chunk引用)。
一切都是这么的自然和谐,惟一不和谐的就是,我犯了这个错误。
以往在研究GC时,就单纯的研究GC,在一张图上经过若干步骤进行mark,再进行若干步骤进行sweep。
在编写Lua代码时,却往往根据以往的c/c++经验来判断变量的生命周期, 毕竟就算在如java,C#这些带GC的面向对象语言中,这些经验依然适用。
也因此,在我面向对象编程范式(也许叫‘基于对象’更合适,毕竟我极少使用继承)的思维惯性下,潜意识竟然将这两个紧密相关的部分,强行割裂开来。
以往写Lua代码时,我一直以为Lua是“原型对象”编程范式,然而这个“大跟头”让我发现,原来Lua的底层基石竟然是“函数式编程”范式(非纯函数式编程语言,Lua中的函数有副作用)。
在我们写代码之初,就被人谆谆教导:“程序=算法+数据结构”。
过一段时间(也许很久),我们又被教导各种编程范式,如:“面向对象编程范式,函数式编程范式”。
接着你就会问:“什么是函数式编程,什么是面向对象编程?”
会有很多人告诉你:“在函数式编程语言中,函数是一等公民。在面向对象编程中,万物皆对象”。
然后你(主要是我自己)就开始似懂非懂的用这些概念去“忽悠”其他人。
却从来没在意过,整个编程范式中,数据的生命周期是以何种方式被管理着,以及数据在以何种方式进行转换和通信。
借着这个Bug的契机,我从数据的视角来重新审视了一下这些话,有了一些意想不到的发现。这次终于打破了以往的范式惯性(上次学Lua时,我也是自信满满的认为我懂了函数式编程,结果摔了个大跟头)。
先来大致看看面向对象的哲学。
在纯面向对象编程语言中(C++显然不算),所有的逻辑交互均是在对象之间产生的,不允许变量产生在对象之外。
即使他们在努力的模仿函数式编程,比如所谓的委托,匿名函数。然而这些函数背后却总是逃不开this指针的影子。比如下面代码:
using System;
using System.IO;
namespace test {
class TestClass {
public string a;
public delegate int foo_t();
public int bar() {
foo_t func = ()=>{Console.Write(a + "\n"); return 0;};
func();
return 1;
}
};
class Program {
static void Main(string[] args)
{
TestClass tc = new TestClass();
tc.a = "foo";
TestClass.foo_t cb = tc.bar;
cb();
}
}}
再来看看函数式编程范式中一等公民的定义:"如果一个语言支持将函数作为参数传入其他函数,将其作为值从其他函数中返回,并且将它们向变量赋值或将他们存储在数据结构中,就在这门语言中,函数是一等公民。
我认为对于有C/C++背景的人来讲,这不足以解释函数式编程的特点。
因为在C/C++语言中,函数指针同样可以做到上述所有的事情。
惟一的区别就是函数式编程语言中的函数其实是闭包(所需要的上下文+指令码(也许是CPU指令,也许是VM的OPCODE)),而C语言中的函数就真的是一段CPU指令。这两种函数有着本质上的区别。
类比面向对象是万物皆对象,函数式编程就应该是万物皆函数。
而实现万物皆函数,闭包是函数式编程必不可少的条件(这里不讨论纯函数式编程范式,连LISP都不是纯函数式编程语言)。
在函数式编程范式中,所有的逻辑交互均是以函数(闭包)为主体来运行。
每一个函数会携带自身所需的环境变量,以便在任何需要执行的地方执行。
自身的GC机制会保证,在函数(闭包)没有被回收前,其携带的环境变量永远有效。
在Lua的require和chunk的机制中我摔的跟头充分验证了这一点。
最后让我绞尽脑汁举一个不太恰当的例子来收尾吧(毕竟我也是刚刚(自以为)重新认识了函数式编程):
local function travel(tbl, process)
return function()
for k, v in pairs(tbl) do
process(k,v)
end
end
end
local function printx(title)
return function(x, y)
print(title, x, '=>', y)
end
end
local tbl = {"foo", "bar"}
local func = travel(tbl, printx("index:"))
tbl = {"newfoo", "newbar"}
----other logic
func()