上一篇谈了对未来并发程序的一些预想,今天来谈谈如何在并发程序中运用Transaction Object实现无锁并发。
Transaction Object这一概念来自于数据库,现在的数据库一般都能很好的支持并发访问,对于只读数据来说天生就可以被并发的访问而不需要任何同步机制,主要问题在于写入上,当对一个数据同时进行多次写入操作的时候,操作之间就会互相影响,从而产生错误结果,同样在写入过程中如果读取数据可能会导致读到错误的数据。因此,首先我们必须让写入操作串行化的进行,其次更新过程中数据的读取要保证是一致的,也就是要么是更新前的数据要么是更新后的数据,而不会读到更新过程的中间状态。最后如果更新失败,应该可以把数据还原成更新前的状态。数据库运用Transaction实现了这些原则。
那么我们是否可以把这个概念移植到更一般化的程序中来呢?考察这样一类系统,它在同一时间里维持着一组对象,不停的保持这些对象的添加、删除、更新还有对象之间的交互。对象的更新一般来自于外部事件(例如外部输入,时间流逝等等),同时对象与对象之间也存在着互相的影响。
现在让我们来看更新和交互这两个过程,不外乎就是读取对象状态、按照一定的逻辑规则进行计算然后把结果写回到对象里这样三个步骤,如果现在我们把最后一步写入分离出来,把它放入一个队列中以便稍后执行,这样做会有什么好处呢?因为我们不会立刻改变对象状态,所以我们能够在同一个时间发起一组对象的更新和交互的并发操作,此时读取和计算对象状态都是安全的,不需要任何同步机制,当所有的计算全部完成以后我们在一个单独的线程内串行化的把结果写回,因为此时只有写入操作,这个过程可以很快的被执行,最终完成整套更新流程。至于对象的添加和删除,因为对象是放在容器中进行管理的,如果我们把容器也当作对象同样的来处理,这个问题也可以得到一致的处理。
这么做会造成什么后果呢?首先在一次并发的计算过程中对象状态始终不会改变,考虑极端一点的情况,如果我们永远不更新对象状态,那么系统将永远不会演化,但是如果我们只是在很小一段时间片内让对象状态只读,那么大多数系统是可以容忍这种情况;其次写入操作虽然是串行执行的,但因为是并发线程发起的,所以时序是乱的,如果写入依赖于时序那么这种方法就会失效,但是因为操作是在很小一段时间片内发起的,我们可以近似的认为这些操作是同时产生的,可以不关心时序;最后,写入操作之间可能存在冲突和互相矛盾,例如两个对象同时需要占用某个只能被独占的对象,这样会涉及到三个对象的写操作,如果不加判断就会引起冲突,对于这个问题我们可以加入一定的仲裁机制来解决;还有一种情况是两个操作同时去修改某个变量值,此时我们可以保存变量的改变量而不是变量值本身,这样最后写入的时候就可以合并两个操作了。
可以看到,这种对象的更新过程具有一定的Transaction特征,因此我们把这类对象称为Transaction Object。
下面我们以游戏中的物理模拟为例来看看如何运用Transaction Object。游戏中的物理模拟一般是这样一个过程:
proc physical_simulate
foreach objects
collision_detect object with other_objects
simulate object deltatime
第一步,检查对象之间的碰撞(对象的交互过程),如果发生碰撞则改变对象状态
第二步,以一定间隔的时间片模拟对象的运动(对象的更新),刷新位置、速度等状态。
如此循环往复实现物理系统的持续运行。
现在我们运用Transaction Object来并行化这个计算过程。
proc object_simulate_task
object_state_change = simulate object deltatime
add object_state_change to object_state_list
proc object_collision_task
object_state_change = collision_detect object with other_objects
add object_state_change to object_state_list
proc physical_simulate
foreach objects
add object_collision_task to thread_pool
wait_for thread_pool
foreach thread
update thread.object_state_list
foreach objects
add object_simulate_task to thread_pool
wait_for thread_pool
foreach thread
update thread.object_state_list
update object_state_list
可以看到,我们将物理计算中最耗时的碰撞检测和运动模拟并行化到了线程池中并行计算,但是在碰撞检测和运动模拟的过程我们并不把计算结果立刻写回对象中,而是先放到一个队列中(注意:我们可以为每个线程单独准备一个队列,而不使用单一的队列,这样可以避免队列操作的同步化)。等到计算全部完成再将结果更新到对象中去。整个计算过程不需要任何同步锁,每个计算线程都可以顺畅的并发执行并且无需担心死锁和同步问题。
推而广之,我们还可以把这个方案套用到游戏逻辑的计算上,在网络游戏服务器上也可以采用这样的方案实现并行化计算。
最后我们再来看看具体到语言层面我们如何设计Transaction Object,首先我们要保证这些对象对外是完全只读的,所有的方法也必须是只读的,对于会修改对象状态的方法要返回一个状态改变对象,保存于队列中。用C++实现的话可以这样:
class Object
{
friend class ObjectStateUpdate;
protected:
Vector3 position;
Vector3 velocity;
Vector3 acceleration;
Shape shape;
Mass mass;
//...
public:
Vector3 GetLocation() const;
Vector3 GetVelocity() const;
Vector3 GetAcceleration() const;
ObjectStateUpdator* SetPosition(Vecotr3 pos)
{
return new ObjectStateUpdator(this, pos - position, 0, 0);
};
ObjectStateUpdator* SetVelocity((Vecotr3);
ObjectStateUpdator* SetAcceleration((Vecotr3);
ObjectStateUpdator* UpdateObject();
//...
};
class ObjectStateUpdator
{
Object* obj;
Vector3 deltaPosition;
Vector3 deltaVelocity;
Vector3 deltaAcceleration;
public:
ObjectStateUpdator(Object* obj_, Vector3 deltaPosition_, Vector3 deltaVelocity_, Vector3 deltaAcceleration_);
void Apply()
{
obj->position += deltaPosition;
obj->velocity += deltaVelocity;
obj->acceleration += deltaAcceleration;
}
};