写入位置发生访问冲突_多线程访问-BTree之线程安全

408882940f20a78de4cea417c78bef8e.png

上篇文章讨论了如何实现一个内存中的BTree,实现了BTree基本的功能,但是该BTree的实现并不是线程安全的,不支持多个线程同时访问。在现实情况中,数据库存储引擎需要支持多个线程同时访问,以提高访问的并发度。本文将介绍如何实现一个线程安全的内存版BTree,以及实现过程中的一些注意事项。

线程不安全

上篇文章中实现的BTree是线程不安全的,它不支持多个线程同时访问(可以同时读)。下面介绍两种可能的线程不安全情况(实际上,有多种不同的线程不安全的情况)。

访问越限

以下的访问可能导致访问越限,LeafNode节点只有N-1个key,但是会出现访问到第N个key的情况,这样就可能出现不可知的错误(具体错误取决于实现)。

  1. A线程访问LeafNode节点,先取得LeafNode中key的个数为N;
  2. B线程访问同一个LeafNode节点;
  3. B线程删除LeafNode节点中的一个key(delete操作);
  4. A线程遍历第1到N个key,查找到对应的ValueNode,访问第N个key时,出现错误,因为第N个key已经不存在了。

下图为访问越限的错误流程:

3252e667e6bf7f8847b36216333a41c4.png

6def194934684e7e65786ff78d4a5cb2.png

节点丢失

节点分裂时,如果有其他线程访问分裂的节点,可能导致找不到正确的节点,读不到正确的ValueNode,也就是出现ValueNode丢失的情况。ValueNode节点丢失发生的访问顺序如下:

  1. 线程A访问一个节点,正准备查询key=5的节点;
  2. 线程B访问该节点,发现节点已经满了N=4,尝试分裂该节点
  3. 线程B分裂该节点,key=5复制到新的节点中;
  4. 线程A在就节点上找不到key为5的子节点,返回找不到key=5的值,出现节点丢失的情况,key=5的值在BTree是存在的。

下图为该情况的流程:

b2768d8b0f9e6227bf3446df39dc65e2.png

0994983712a348b9c318102487a90777.png
提问:还有没有其他线程不安全的情况?可以一一列出来。

线程安全实现途径

通过分析上面两种线程不安全,可以发现线程不安全的原因是因为出现了多个线程交替访问的情况。换句话说,就是访问的原子性被打破了。为了实现线程安全,可以通过一些手段保持访问的原子性。比较常见的方式,是通过锁来实现访问的原子性,本文将只讨论使用锁来实现线程安全。

提问:还有没有其他方式可以实现线程安全?如果有,是什么方法?哪些系统上使用了这些方法?它的优缺点是什么?

Java实现锁有两种方式,一种是lock,也就是常规意义上的锁;另外一种是synchronized,同步块或者同步方法,其作用和lock基本相同,使用{}来标识加锁和解锁。本文为了考虑各种语言的不同,选择使用lock,不使用synchronized。

线程安全实现之全局锁

最直接的线程安全方式就是在每一个操作开始的时候加锁,在操作结束的时候解锁。实现如下:

private final ReentrantLock treeLocker = new ReentrantLock(); //在BTree中增加一个锁

public void put(byte[] key, byte[] value) {
        try {
            treeLocker.lock();
			//put逻辑代码
        } finally {
            treeLocker.unlock();
        }
}

public byte[] get(byte[] key) {
        try {
            treeLocker.lock();
			//get逻辑代码
        } finally {
            treeLocker.unlock();
        }
}

public byte[] delete(byte[] key) {
        try {
            treeLocker.lock();
			//delete逻辑代码
        } finally {
            treeLocker.unlock();
        }
}

线程安全实现之全局读写锁

上面的实现,实际上是让所有的操作排队,然后一个个按照顺序访问BTree。这样的方式是可以保证访问的原子性,杜绝了线程不安全,但是降低了访问的并发性。如果所有的访问都是查询,依然需要排队,但是此时一起访问并不会出错(只有查询不改变BTree,访问不会出现任何异常)。下图展示的是两个查询操作访问时的情景:

b123b53dd678e9e2e9b8c5daf11c0c78.png

Java还提供一种读写锁,这种锁有一个读锁模式,读锁模式相互之间不阻塞,但是读写模式以及写模式相互直接阻塞。这样就可以让两个执行读操作的线程不用阻塞等待,从而提高并发度。读写模式阻塞表如下:

1d9324dfbd54067aaf7b9d58ea69dea0.png

使用读写锁以后,get操作使用读锁,delete和put操作使用写锁,因为delete和put操作会修改BTree的状态。

两个读操作的图示如下:

77aac67cf7aafe030734547c2266b365.png

修改后的代码如下:

private final ReentrantReadWriteLock treeLocker = new ReentrantReadWriteLock();
public void put(byte[] key, byte[] value) {
        try {
            treeLocker.writeLock().lock();
			//put逻辑代码
        } finally {
            treeLocker.writeLock().unlock();
        }
}

public byte[] get(byte[] key) {
        try {
            treeLocker.readLock().lock();
			//get逻辑代码
        } finally {
            treeLocker.readLock().unlock();
        }
}

public byte[] delete(byte[] key) {
        try {
            treeLocker.writeLock().lock();
			//delete逻辑代码
        } finally {
            treeLocker.writeLock().unlock();
        }
}

线程安全实现之节点锁

以上的线程安全实现都是锁住了整个BTree,第二个实现虽然有一定的优化,但是依然是锁住整个BTree。当一个读操作先取得了读锁,后面的其他写操作依然等待该操作结束时,才能取得写锁,然后才能对BTree进行操作。

208c9a917a20f1d22722eed065a38c65.png

即使A线程访问到非根节点,B线程依然在根节点等待。

66792cd04a464997adfc871742686a1b.png

从B线程未来需要访问的节点来看,可以很容易知道,B线程和A线程在接下来不会访问同一个节点了,二者未来也不会发生冲突,A线程在BTree上加的锁可以释放,这样B线程就不用等到A线程结束以后,才能访问BTree,从而提高了访问的并发度。不过直接释放A线程的锁,会出现一些并发的问题。为了提高BTree访问的并发度,加锁粒度从BTree级别缩小为node级别(也有叫page级别),需要遵循一定的协议:

  1. 对某个节点加锁前,需要对其父节点加锁
  2. 对子节点加锁后,才能释放父节点的锁
  3. 加锁从根节点开始
  4. 不能逆向加锁,即不能先锁子节点,再锁父节点

这种加锁协议一般称作ladder locking或者coupling locking,大致过程如下:

2832bbb7f501c19d8c534952e1e6e2a2.png
提问:除了这种加锁方式,是否存在其他方式可以提高并发度的?有哪些系统使用了这种加锁方式,有哪些系统使用了其他的方式?

数据结构修改

因为需要对具体某个节点加锁,所以需要在给所有TreeNode增加一个锁的字段。如果一个操作是查询操作,它对节点可以只加读锁;而如果一个操作是写入操作,它可能需要对节点加写锁,所以给TreeNode增加的锁字段,应该是一个读写锁,另外需要增加三个新方法,分别用于加读锁、加写锁以及释放锁,如下所示:

public abstract class TreeNode extends Node {
    //最大子节点数目
    public static final int MAX_CHILDREN = 256;
 
    protected final ReentrantReadWriteLock locker = new ReentrantReadWriteLock();
 
    public abstract Node findChild(DataItem key);
    public abstract void insertChild(DataItem key, Node child);
    public abstract Node removeChild(DataItem key);
    public abstract TreeNode split(TreeNode father);
    public abstract boolean needSplit();
 
    public void writeLock() {
        locker.writeLock().lock();
    }
 
    public void readLock() {
        locker.readLock().lock();
    }
 
    public void releaseLock() {
        if (locker.writeLock().isHeldByCurrentThread()) {
            locker.writeLock().unlock();
            return;
        }
 
        locker.readLock().unlock();
    }
}

Root的父节点

Coupling Locking的规则要求对某节点加锁时,需要先对其父节点加锁,但是Root节点没有父节点,无法对父节点加锁。但是如果不加锁会出现一些并发的问题,比如两个写操作同时发现root不存在,都尝试建立一个root节点,这样就可能出现两个root节点,最后有一个操作的写入会丢失。为了解决这个问题,需要引入一个root节点的“父节点”,这个父节点就是BTree本身,使用BTree本身的读写锁作为root节点的父节点的锁。

读操作的Coupling Locking

读操作的Coupling Locking全程使用读锁,其流程如下:

  1. 对整个BTree加读锁;
  2. 找到root节点以后,对root节点一个读锁,然后释放BTree上的读锁;如果root不存在,直接返回;
  3. 接下来,每次找到子节点,在子节点上加上读锁以后才能释放父节点上的读锁;
  4. 如果最后的子节点是LeafNode,从上面读取目标key对应的Value,然后在释放LeafNode上的锁

下图是查询key=1的过程:

1538366d54fc91b3343199de3a2cba8e.png

代码如下

    public byte[] get(byte[] key) {
        DataItem searchKey = new DataItem(key);

        treeLocker.readLock().lock();
        if (root == null) {
            treeLocker.readLock().unlock();
            return null;
        }
        TreeNode parent = root;
        parent.readLock();
        treeLocker.readLock().unlock();
        while (parent instanceof InterNode) {
            TreeNode child = (TreeNode)parent.findChild(searchKey);
            child.readLock();
            parent.releaseLock();
            parent = child;
        }

        ValueNode valueNode = (ValueNode)parent.findChild(searchKey);
        parent.releaseLock();

        if (valueNode == null) {
            return null;
        }

        byte[] value = valueNode.getValue().getData();
        if (value == null) {
            return null;
        }
        return Arrays.copyOf(value, value.length);
    }

删除操作的Coupling Locking

删除操作的Coupling Locking中,需要对InterNode使用读锁,对LeafNode使用写锁,其流程如下:

  1. 对整个BTree加读锁;
  2. 找到root节点以后,如果root是InterNode,对root节点一个读锁,如果root是LeafNode,则加写锁,然后释放BTree上的读锁;如果root不存在,直接返回;
  3. 接下来,每次找到子节点,在子节点上加上读锁(InterNode)或者写锁(LeafNode),以后才能释放父节点上的锁;
  4. 如果最后的子节点是LeafNode,则删除目标key对应的Value,然后在释放LeafNode上的锁

下图是删除key=1的过程:

9cbfc2565b3d0d5ee5de28e81e70bad6.png

代码如下:

    public byte[] delete(byte[] key)  {
        DataItem deleteKey = new DataItem(key);

        treeLocker.readLock().lock();
        if (root == null) {
            treeLocker.readLock().unlock();
            return null;
        }

        TreeNode parent = root;
        if (parent instanceof LeafNode) {
            parent.writeLock();
        } else {
            parent.readLock();
        }
        treeLocker.readLock().unlock();
        while (parent instanceof InterNode) {
            TreeNode child = (TreeNode)parent.findChild(deleteKey);
            if (child instanceof LeafNode) {
                child.writeLock();
            } else {
                child.readLock();
            }
            parent.releaseLock();
            parent = child;
        }

        ValueNode valueNode = (ValueNode)parent.removeChild(deleteKey);
        parent.releaseLock();

        if (valueNode == null) {
            return null;
        }

        byte[] value = valueNode.getValue().getData();
        if (value == null) {
            return null;
        }

        return Arrays.copyOf(value, value.length);
    }

写操作的Coupling Locking

写操作的Coupling Locking全部为写锁,这是因为在写操作的过程可能会出现分裂的情况,其具体过程如下:

  1. 对整个BTree加写锁;
  2. 查找root,然后加写锁;没有找到root,创建一个新的root,再对root加写锁
  3. 检查root是否需要分裂;如果需要,先创建一个新的root,然后把旧root插入新root,然后分裂旧root,接着释放旧root上的写锁,在新root上加一个写锁
  4. 接下来,每次找到子节点,在子节点上加上写锁(LeafNode),检查子节点是否需要分裂,如果需要,先分裂子节点,然后释放子节点上的写锁,然后跳回父节点上,重新查找子节点,在新字节上加写锁。最后释放父节点上的写锁;
  5. 如果最后的子节点是LeafNode,插入key=3和value=w,然后在释放LeafNode上的写锁

下图是插入key=3,value=w的过程:

b8299afe23a02068f98a34164588ecc5.png

代码如下:

    //插入key/value对
    public void  put(byte[] key, byte[] value) {
        DataItem insertKey = new DataItem(key);
        DataItem insertValue = new DataItem(value);

        treeLocker.writeLock().lock();
        if (root == null) {
            root = new LeafNode();
        }
        root.writeLock();
        if (root.needSplit()) {
            InterNode newRoot = new InterNode();
            newRoot.insertChild(DataItem.MAX_VALUE, root);
            root.split(newRoot);
            root.releaseLock();
            root = newRoot;
            root.writeLock();
        }

        TreeNode parent = root;
        treeLocker.writeLock().unlock();
        while (parent instanceof InterNode) {
            TreeNode child = (TreeNode)parent.findChild(insertKey);
            child.writeLock();
            if (child.needSplit()) {
                child.split(parent);
                child.releaseLock();
            } else {
                parent.releaseLock();
                parent = child;
            }
        }
        ValueNode valueNode = new ValueNode(insertValue);
        if (parent.needSplit()) {
            System.out.println("Something Wrong");
            if (parent == root) {
                System.out.println("Root is not splitted, werid!!!");
            }
        }
        parent.insertChild(insertKey, valueNode);
        parent.releaseLock();
}
提问:写操作的Coupling Locking还能进一步优化吗?在什么场景下,能够提高性能?

代码

本章中提及的完整代码如下:(全部代码将在下一章提供)

总结

到本章为止,我们完整实现了内存BTree,包括增删查功能以及线程安全功能,但是这些功能都是在内存中,一旦断电或者内存不够,就会出现数据丢失,所以需要把内存中的数据写入文件中,这样才能保证数据不丢失(硬盘不损坏的情况下)。下章中,我们将讨论如何把BTree中的数据写入文件。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值