上一节描述了如何使用c语言的函数指针实现一个结构体接口,从而实现了类似于面向对象的封装机制,多态性只是其中一个方便的地方,更为重要的是,它使用消息机制代替了值的更改,所以我们可以不再使用传统的获取状态然后赋值的方式来实现一个系统,在正常情况下,我们获得了抽象机制,这好像已经足够强大了,但是还不够,在当今网络服务的兴起情况下,由于并发系统的流行,我们将看到这样的抽象方法论的真正威力。
读者可以复习一下之前的文章,因为这个模型也是使用之前的c语言语法来实现。
“这就创建了一种简单的时态逻辑,将“事实集合”显现为世界线的层层堆栈”
首先我们需要一个并发执行的函数。
typedef struct shareOBJ shareOBJ;
struct shareOBJ{
int id;
...
};
void pthread(void * obj){
shareOBJ * object = (shareOBJ*)obj;
message * client_message = obj->client_message;
obj->exec(client_message);//获取客户端命令并执行
}
int main(){
shareOBJ * obj = new_ShareOBJ();//初始化内存空间
while(1){
obj->accept();//阻塞等待服务器消息
pthread_create(NULL,NULL,pthread, (void*)obj);//开启新线程处理
}
}
我们可以看到,现在的逻辑处理是完全封闭的,具体的业务是什么?不知道,内部的逻辑是什么,也不知道,我们只知道,现在每到来一个请求,我们就开启一个线程去处理,其中客户端消息全部存储到我们的shareOBJ这个类型的结构体里面。
我们暂且按照具体的情况来处理,假如现在我们在shareOBJ结构体内部维护了这样一组数据:客户端的消息:message,账户总额:cash,客户余额表:table.
{
...
message * msg;
float cash;
table * table;
...
}
通常的情况下,假如来了一个客户要取钱,我们可以这样处理,将客户的余额按照他的表中的账户进行计算,然后将表赋值为更改之后的值,由于表中的数据在同一时间内,一个客户只能执行对应自己账户的一次操作,所以我们的数据不会出现任何问题。
//接口1:将客户的账户余额按照客户指定的存取方式进行核算
computeClient(table*tab,message*msg){
tab->data[msg->clientID] = tab->data[msg->clientID] + msg->putgetCash;
}
但是我们同时还要计算账户的总额,这个时候如果再进行更改和赋值,就会出现大问题了,因为客户存取逻辑下拿到的总余额数据是不一样的,我们具体来看。
//接口2:计算银行的总额
computeBank(float * cash,message * msg){
*cash = *cash + msg->putgetCash;
}
现在的执行逻辑是这样:
cash0进入客户1,cash0进入客户2,cash0进入客户3......
客户1存钱或者取钱,得到了一份拷贝cash1,
客户2存钱或者取钱,得到了一份拷贝cash2,
客户3存钱或者取钱,得到了一份拷贝cash3.
我们知道cash1和cash2还有cash3是cash0在不同的客户逻辑下得到的存取数目,而我们每一次得到的数据都是并发执行的,这样的总额将会出现奇怪的变化。
假如按照上面的逻辑来计算,我们下一次得到的必定是cash1,现在我们的第四个客户来了,他是我们的银行老板,使用cash1结算后的银行总额来查帐本,却发现,明明有3个人来存了钱,但是现金库里和账户点算出来只有客户1一个人存了钱,导致我们的现金还足余,银行账本上已经没钱了,难道是因为客户2和3的钱还没有打到账上吗?仔细想想,我们无论哪一个人先存钱,账本都是按照之前的那个没有更新的账本在计算,并且将任意一个人的计算结果放到了总的余额中,所以导致了这样的情况。
出现问题的根本原因就是我们采用了并发的策略,而不是排队的方式来处理我们的计算过程,每一个上下文引用后的变量都是分离的,最终又把在时间中丢失一部分状态后的这些操作直接安装到原来的时间线上,我们完全可以使用排队的方式处理这个问题,从一个客户进入处理流程,再到结束办理,我们再执行等待处理下一个客户的业务,但是这样可能会频繁切换上下文,导致的性能开销也是及其昂贵的,甚至是无法忍受的,因为一个业务流程中,最消耗时间的并不是对余额进行计算,而是对客户数据的计算,而我们的客户数据是可以并发执行没有问题的,所以一个可行的解决方案就是将客户数据的业务推入并发流程中,而余额等一系列共享变量的计算使用循环等待的方式解决,但是这仍然具有很大的性能消耗,有没有一种办法既可以不用频繁切换上下文,又可以把丢失的那部分未经计算的状态找回来呢?当然是有的,那就是我们的惰性计算。
惰性计算的好处
惰性计算其实理解起来很简单,就是将原本时间线上本应立即计算的一个对象或者状态延迟一段时间再进行计算,这往往在函数式编程中利用它来实现无穷数列这样的数据结构,又叫做流,在当年那个计算机性能极度缺乏的情况下,这是一种魔法般的武器,不过到了今天,这样的抽象方法论更多地用在并发环境下方便的地操作数据同步和解决锁机制带来的问题。
那么实现的关键步骤在于,我们将结构体shareOBJ中的固定值 float cash 更改为可以存储当前值的历史的一个栈,我们客户端的更改操作可以设置为向栈取出一个当前时间帧上的真实数据,并向其中存入一个更改后的历史值,也就是说,无论我们的时间帧有多么小,取到的数据都是在时间上同步后的数据,因为我们的栈将所有的更改记录都保存下来了,现在关键的地方到了,我们从哪里寻找这样的栈呢?它必须要安装所有的历史记录才可以,好像我们走到了一个想当然的地步,这时候我们的惰性计算出场了,我们完全可以不必提前得到一个无穷大的栈,而只需要在用到下一个栈空间的时候我们再去构造它,听起来好像理所当然,我们用到的下一个栈空间肯定是构造出来的呀,可是总会用完这个栈,很多人可能会想到一个办法,将这块内存作为缓冲区,然后栈满的时候就可以持久化,其实这是一个办法,但是还有更优雅的解决方法,因为我们只关心最终的余额,所以这些实时的计算过程可以另启一个余额计算函数,每隔特定的时间帧或者满足栈满的触发条件就对余额进行核算,并且将栈清空,等待下一轮的访问高峰,因为我们的栈内元素假如为满,是无法再添加元素的,这时候可以返回失败条件,但是我们的余额计算函数速度是很快的,几乎不会占用多少时间,反而是我们的客户数据的计算还有日志存储占用了太多的时间,这个时候我们可以将消息记录中的所有的总余额数据全部写入磁盘进行持久化,客户端的操作什么时候可以返回成功条件呢?当我们的余额计算时间片轮转一次就返回一次成功消息,因为轮转一次代表我们已经处理了一轮时间刚好满足的访问操作,只要满足任意一个条件我们就可以使用余额计算函数返回客户端的操作消息。
看起来我们的计算函数成功了,因为它的计算不再是模拟现实世界的时间线,我们的用户操作并不会立即得到计算结果,而是在我们设计者已经规定的条件下才得到结果,比如栈满或者时间片轮转的时候,这就是一种解耦,将真实世界的时间线解耦到栈中,只要我们拥有完整的消息数据,我们完全可以选择任意时刻计算得到我们想要的结果,这也是函数式编程的思想,引用透明性,我们不需要模拟现实世界的时间走向,可以延迟到任意的时间去计算也不会产生改变,这便是时态逻辑,某些事实在任意时间都成立,无论等一千年还是一万年去把存取值的历史栈取出来计算,我们最终得到的值仍然是一样的,如果在并发中访问固定的变量和状态变化的话,我们将会得到很可怕的结果,因为某些值在时间中消失了!
{
stack * cash;
}
stack.setValue();//实际上此处的setValue已经代替赋值操作
stack.time();//time 函数会计算当前的时延,超过响应时间就将进行计算,并清空栈
stack.push();//push会将新的消息传入栈
stack.compute();//计算函数会立即锁定栈,并计算所有的栈元素相加得到一个稳定计算后的值,然后清空栈
stack.toNULL();//toNULL立即释放栈的空间,并解除锁定,接收客户端请求。
看起来我们的操作好像显得很复杂,但是实际上这样的设计完美地避开了不稳定状态带来的并发问题,并且我们只在执行缓冲更换操作的时候通过内部状态的机制给定了一把不完全是锁的锁,因为它的状态完全由对象本身来决定,而不是外部手动去操作,自然不会存在竞争条件和死锁等问题,并且它的速度和安全性都远远高于使用锁机制,当我们在push的时候,我们的对象会自己内省是否在进行计算操作,否则就返回栈满条件,外部逻辑是执行循环等待还是直接返回错误消息完全取决于外部的系统条件,push更好的方式甚至可以是内部循环等待,外部的操作是无法观察到当前线程引用stack的push操作到底发生了什么,所以我们的并发系统成功运行了,当然这个设计还只是作者的一点思考。
下面是具体的逻辑代码
setValue(){
if(cash.time()==0.2 || cash.state == LOCK){
stack.compute()
stack.unlock()
}
else{
stack.push()
}
}
computeBank(stack * cash,message * msg){
cash.setValue(msg->putgetCash);
}