【问题标题】:Efficient cache synchronization高效的缓存同步
【发布时间】:2011-04-30 08:48:57
【问题描述】:

考虑一下

public Object doGet() {
    return getResource();
}

private Object getResource() {
    synchronized (lock) {
        if (cachedResourceIsStale()) {
            downloadNewVersionOfResource();
        }
    }

    return resource;
}

假设doGet 将同时执行,并且执行很多,并且下载新版本的资源需要一段时间,有没有更有效的方法来在getResource 中进行同步?我知道读/写锁,但我认为它们不能在这里应用。

为什么要同步?如果缓存过时,所有访问资源的线程仍在第一个刷新时,将执行它们自己的刷新。除了这导致的其他问题,它几乎没有效率。

正如 BalusC 在 cmets 中提到的,我目前在 servlet 中面临这个问题,但我对通用答案很满意,因为谁知道在什么情况下我会再次遇到它。

【问题讨论】:

  • 您可以划分页面并重新加载特定部分,而不是重新获取整个页面
  • 您已经编辑了代码以中和 Servlet API,但重要的是要知道这发生在 servlet 中,所以我添加了标记。
  • 定义“很多”;每秒调用多少次? cachedResourceIsStale() 需要多长时间?
  • 如果 doGet() 在 servlet 中针对 HTTP GET 请求运行,并且 write() 修改了资源,那么您就误用了 HTTP。您应该只修改 POST、PUT 和 DELETE 请求中的资源。编辑添加:在 HTTP 服务器的上下文中,单个同步调用可能不值得优化。只在测量后优化,绝不过早。
  • @Spike:不,write() 写入响应。 OP过度简化了这个例子。查看原始代码的问题编辑历史记录。

标签: java multithreading servlets synchronization locking


【解决方案1】:

您可能可以通过使用“lock-striping”来减少锁争用(从而提高吞吐量)——本质上,将一个锁拆分为多个,每个锁保护特定的用户组。
棘手的部分是如何弄清楚如何将用户分配到组。最简单的情况是您可以将来自任何用户的请求分配给任何组。如果您的数据模型要求必须按顺序处理来自一个用户的请求,则必须在用户请求和组之间引入一些映射。这是 StripedLock 的示例实现:

import java.util.concurrent.locks.ReentrantLock;

/**
 * Striped locks holder, contains array of {@link java.util.concurrent.locks.ReentrantLock}, on which lock/unlock
 * operations are performed. Purpose of this is to decrease lock contention.
 * <p>When client requests lock, it gives an integer argument, from which target lock is derived as follows:
 * index of lock in array equals to <code>id & (locks.length - 1)</code>.
 * Since <code>locks.length</code> is the power of 2, <code>locks.length - 1</code> is string of '1' bits,
 * and this means that all lower bits of argument are taken into account.
 * <p>Number of locks it can hold is bounded: it can be from set {2, 4, 8, 16, 32, 64}.
  */
public class StripedLock {
    private final ReentrantLock[] locks;

    /**
     * Default ctor, creates 16 locks
     */
    public StripedLock() {
        this(4);
    }

    /**
     * Creates array of locks, size of array may be any from set {2, 4, 8, 16, 32, 64} 
     * @param storagePower size of array will be equal to <code>Math.pow(2, storagePower)</code>
     */
    public StripedLock(int storagePower) {
        if (storagePower < 1 || storagePower > 6)
             throw new IllegalArgumentException("storage power must be in [1..6]");
        int lockSize = (int) Math.pow(2, storagePower);
        locks = new ReentrantLock[lockSize];
        for (int i = 0; i < locks.length; i++)
            locks[i] = new ReentrantLock();
    }

    /**
     * Locks lock associated with given id.
     * @param id value, from which lock is derived
     */
    public void lock(int id) {
        getLock(id).lock();
    }

    /**
     * Unlocks lock associated with given id.
     * @param id value, from which lock is derived 
     */
    public void unlock(int id) {
        getLock(id).unlock();
    }

    /**
     * Map function between integer and lock from locks array
     * @param id argument
     * @return lock which is result of function 
     */
    private ReentrantLock getLock(int id) {
        return locks[id & (locks.length - 1)];
    }
}

【讨论】:

    【解决方案2】:

    假设

    1. 高效意味着doGet() 应该尽快完成
    2. cachedPageIsStale() 一点时间都没有
    3. downloadNewVersionOfResource() 需要一点时间

    回答

    同步减少了网络负载,因为只有一个线程在资源过期时获取资源。此外,它不会过度延迟其他线程的处理 - 由于 VM 不包含线程可以返回的当前快照,它们将不得不阻塞,并且没有理由额外的并发 downloadNewVersionOfResource() 将更快地完成(我会由于网络带宽争用,预期相反)。

    所以同步是好的,并且在带宽消耗和响应时间方面是最佳的。 (与 I/O 等待相比,同步的 CPU 开销微乎其微)——假设调用 doGet() 时资源的当前版本可能不可用;如果您的服务器始终拥有资源的当前版本,它可以立即将其发回。 (您可能有一个后台线程在旧版本到期之前下载新版本。)

    附言

    您没有显示任何错误处理。您必须决定是将 downloadNewVersionOfResource() 抛出的异常传播给调用者还是继续提供旧版本的资源。

    编辑

    所以?假设您有 100 个连接工作者,检查资源是否过时需要 1 微秒,资源是否过时,服务需要 1 秒。然后,平均有 100 * 10^-6 / 1 = 0.0001 个线程试图获取锁。几乎没有任何争论。获取未使用锁的开销大约为 10^-8 秒。当网络会导致几毫秒的延迟时,优化已经使用微发送的东西是没有意义的。如果你不相信我,做一个同步的微基准。确实,频繁的、不必要的同步会增加大量开销,因此不推荐使用同步集合类。但这是因为这些方法每次调用所做的工作很少,而且同步的相对开销要大得多。我刚刚为以下代码做了一个小的微基准测试:

    synchronized (lock) {
        c++;
    }
    

    在我的笔记本上,这需要 50 纳秒(5*10^-8 秒),在 sun 的热点 vm 中平均执行超过 1000 万次。这大约是裸增量操作的 20 倍,所以如果一个人做了很多增量,同步每个增量会使程序减慢一个数量级。但是,如果该方法确实阻塞了 I/O,例如等待 1 毫秒,那么添加相同的 50 纳秒会降低 0.005% 的吞吐量。当然,您有更好的机会进行性能调整:-)

    这就是为什么您应该始终在开始优化之前进行测量。它可以防止您花费数小时来节省几纳秒的处理器时间。

    【讨论】:

    • 感谢您解释为什么同步很好,但我已经知道了。我感兴趣的是优化它。大多数情况下,缓存不会过时,但所有请求仍然需要一个接一个地锁定、验证和解锁。
    • 添加了更多细节,说明在这种情况下同步开销无关紧要。
    • 我同意meriton。与服务请求的成本相比,该代码块的成本可以忽略不计。即使您完全优化该部分并使其速度提高 100 倍,吞吐量的增益也将接近于零。以防万一,在生产负载下运行分析器进行确认。
    猜你喜欢
    • 2019-04-10
    • 2016-02-08
    • 1970-01-01
    • 2011-01-20
    • 1970-01-01
    • 2014-02-12
    • 2021-01-11
    • 1970-01-01
    • 2010-10-22
    相关资源
    最近更新 更多