同时配对

时间:2013-05-09 18:49:40

标签: java concurrency

我正在寻找一种java并发习惯用法来匹配具有最高吞吐量的大量元素。

考虑我有多个线程进来的“人”。每个“人”都在寻找一场比赛。当它找到另一个等待的“人”时,它们会相互分配并被移除以进行处理。

我不想锁定一个大结构来改变状态。考虑Person有getMatch和setMatch。在提交之前,每个人的#getMatch都是null。但是当他们解锁(或被捕获)时,他们要么已经过期了,因为他们等待渴望比赛,或者#getMatch是非空的。

保持高通过率的一些问题是,如果PersonA与PersonB同时提交。它们相互匹配,但PersonB也匹配已经在等待的PersonC。 PersonB的状态在提交时变为“可用”。但是当PersonB与PersonC匹配时,PersonA不需要偶然获得PersonB。合理?另外,我想以异步方式执行此操作。换句话说,我不希望每个提交者都必须在具有waitForMatch类型的线程上持有Person。

同样,我不希望请求必须在不同的线程上运行,但是如果有一个额外的匹配器线程则可以。

似乎应该有一些成语,因为它似乎是一个非常常见的事情。但我的谷歌搜索已经枯竭(我可能使用了错误的条款)。

更新

有一些事情让我很难解决这个问题。一个是我不想在内存中有对象,我想让所有等待的候选人都使用redis或memcache或类似的东西。另一个是任何人可能有几个可能的比赛。考虑如下界面:

person.getId();         // lets call this an Integer
person.getFriendIds();  // a collection of other person ids

然后我有一个看起来像这样的服务器:

MatchServer:
   submit( personId, expiration ) -> void // non-blocking returns immediately
   isDone( personId ) -> boolean          // either expired or found a match
   getMatch( personId ) -> matchId        // also non-blocking

这是一个休息界面,它会使用重定向,直到你得到结果。我的第一个想法是在MatchServer中有一个Cache,它由redis之类的东西支持,并且对于当前被锁定和被操作的对象具有并发的弱值哈希映射。每个personId将由持久状态对象包装,状态为已提交,匹配和过期。

到目前为止?非常简单,提交代码完成了初始工作,它是这样的:

public void submit( Person p, long expiration ) {
    MatchStatus incoming = new MatchStatus( p.getId(), expiration );
    if ( !tryMatch( incoming, p.getFriendIds() ) )
        cache.put( p.getId(), incoming ); 
}

public boolean isDone( Integer personId ) {
    MatchStatus status = cache.get( personId );
    status.lock();
    try {
        return status.isMatched() || status.isExpired();

    } finally {
        status.unlock();
    }
}

public boolean tryMatch( MatchStatus incoming, Iterable<Integer> friends ) {
    for ( Integer friend : friends ) {
        if ( match( incoming, friend ) )
            return true;
    }

    return false;
}

private boolean match( MatchStatus incoming, Integer waitingId ) {
    CallStatus waiting = cache.get( waitingId );
    if ( waiting == null )
        return false;

    waiting.lock();
    try {
        if ( waiting.isMatched() )
            return false;

        waiting.setMatch( incoming.getId() );
        incoming.setMatch( waiting.getId() );

        return true
    } finally {
        waiting.unlock();
    }
}

所以这里的问题是,如果两个人同时进来并且他们是他们唯一的比赛,他们就不会找到对方。竞争条件对吗?我能看到解决它的唯一方法是同步“tryMatch()”。但这会影响我的吞吐量。我不能无限期地循环tryMatch,因为我需要这些非常短的调用。

那么有什么更好的方法来解决这个问题呢?我提出的每一个解决方案都会一次一个地强迫人们使用吞吐量。例如,创建后台线程并使用阻塞队列一次放入和接收传入线程。

非常感谢任何指导。

4 个答案:

答案 0 :(得分:2)

您可以使用ConcurrentHashMap。我假设您的对象具有可以匹配的密钥,例如PersonA和PersonB将拥有一个&#34; Person&#34;键。

ConcurrentHashMap<String, Match> map = new ConcurrentHashMap<>();

void addMatch(Match match) {
    boolean success = false;
    while(!success) {
        Match oldMatch = map.remove(match.key);
        if(oldMatch != null) {
            match.setMatch(oldMatch);
            success = true;
       } else if(map.putIfAbsent(match.key, match) == null) {
            success = true;
       }
   }
}

在您将匹配项添加到地图之前,或者直到您删除了现有匹配项并将其配对为止,您才会继续循环播放。 removeputIfAbsent都是原子的。

修改:因为您要将数据卸载到磁盘,所以您可以使用例如MongoDB为此,使用findAndModify方法。如果具有密钥的对象已经存在,则该命令将删除并返回它,以便您可以将旧对象与新对象配对,并且可能存储与新密钥关联的对;如果具有该键的对象不存在,则该命令将该对象与该键一起存储。这相当于ConcurrentHashMap的行为,除了数据存储在磁盘而不是内存中;你不必担心同时写两个对象,因为findAndModify逻辑可以防止它们无意中占用同一个密钥。

如果您需要将对象序列化为JSON,请使用Jackson

有Mongo的替代品,例如DynamoDB,虽然Dynamo只能免费获取少量数据。

编辑:鉴于朋友列表不是自反性的,我认为您可以使用MongoDB(或其他键值数据库与原子更新)和ConcurrentHashMap的组合来解决这个问题。

  1. MongoDB中的人员匹配&#34;或&#34;无与伦比。&#34; (如果我说&#34;从MongoDB中移除一个人&#34;,我的意思是&#34;将该人的状态设置为匹配。&#39;&#34;)
  2. 当您添加新人时,请先为其创建ConcurrentHashMap<key, boolean>,可能是全局ConcurrentHashMap<key, ConcurrentHashMap<key, boolean>>
  3. 透过新人的朋友:
  4. 如果朋友在MongoDB中,请使用findAndModify以原子方式将其设置为&#34;匹配,&#34;然后将新人写入MongoDB,状态为&#34;匹配,&#34;最后将这一对添加到&#34; Pairs&#34; MongoDB中可以由最终用户查询的集合。从全球地图中删除此人ConcurrentHashMap
  5. 如果朋友不在MongoDB中,请检查该朋友是否已写入当前朋友的关联ConcurrentHashMap。它有,然后什么都不做;如果没有,则检查该朋友是否与ConcurrentHashMap相关联;如果是,则将与当前人的关键字相关联的值设置为&#34;为真。&#34; (请注意,由于当前的人无法检查自己的地图并使用一个原子操作修改朋友的地图,因此两位朋友仍然可以写对方&#39;哈希地图,但自哈希映射检查减少了这种可能性。)
  6. 如果该人没有匹配,则将其写入&#34;无法匹配的&#34;状态,从全局地图中删除其ConcurrentHashMap,并创建一个延迟任务,该任务将迭代写入该人ConcurrentHashMap的所有朋友的ID(即使用{{1} }})。此任务的延迟应该是随机的(例如ConcurrentHashMap#keySet()),以便两个朋友不会同时尝试匹配。如果当前的人没有任何需要重新检查的朋友,那么就不要为此创建延迟任务。
  7. 当延迟结束时,为此人创建一个新的ConcurrentHashMap,从MongoDB中删除不匹配的人,然后循环回到步骤1.如果该人已经匹配,则不要将其从MongoDB中删除并终止延迟的任务。
  8. 在一般情况下,一个人要么与朋友匹配,要么在没有朋友加入系统的同时迭代朋友列表(即人Thread.sleep(500 * rand.nextInt(30))将是空的)。如果同时写朋友:

    同时添加了Friend1和Friend2。

    1. Friend1写信给朋友2 ConcurrentHashMap,表示他们错过了对方。
    2. Friend2写信给Friend1&#39; s ConcurrentHashMap表示相同(只有当Friend2要检查Friend1是否在Friend1写信的同时写入其地图时才会发生这种情况) Friend2会检测到Friend1已写入其地图,因此不会写入Friend1的地图)。
    3. Friend1和Friend2都写信给MongoDB。 Friend1在其后续任务中随机延迟5秒,Friend2随机延迟15秒。
    4. Friend1的任务首先触发,并与Friend2匹配。
    5. Friend2的任务激发第二; Friend2已不在MongoDB中,因此任务会立即终止。
    6. 一些小问题:

      1. Friend1和Friend2可能不会ConcurrentHashMap与他们相关联,例如如果Friend2在Friend1检查以查看地图是否在内存中时仍在初始化其哈希映射。这很好,因为Friend2会写入Friend1的哈希映射,因此我们保证最终会尝试匹配 - 至少其中一个将具有哈希映射而另一个迭代,因为哈希地图创建先于迭代。
      2. 如果朋友和朋友分别进行匹配的第二次迭代可能会失败。任务以某种方式同时解雇。在这种情况下,如果朋友在匹配状态的MongoDB中,他们应该从其列表中删除朋友;然后,他们应该将结果列表与写入ConcurrentHashMaps的朋友列表结合起来,然后下一次迭代应该将其用作新朋友列表。最终,该人将被匹配,否则该人将重新检查&#34;朋友名单将被清空。
      3. 你应该增加每次后续迭代的任务延迟,以增加两个朋友的概率。任务不会同时运行(例如第一次迭代时为ConcurrentHashMap,第二次迭代时为Thread.sleep(500 * rand.nextInt(30)),第三次迭代时为Thread.sleep(500 * rand.nextInt(60))等。)
      4. 在后续迭代中,您必须在从MongoDB中删除此人之前创建一个人的新Thread.sleep(500 * rand.nextInt(90)),否则您将进行数据竞争。同样,您必须在迭代其潜在匹配项时从MongoDB中删除某个人,否则您可能会无意中将其匹配两次。
      5. 修改:部分代码:

        方法 ConcurrentHashMap 会写一个&#34;无法匹配的&#34; person1到MongoDB

        addUnmatchedToMongo(person1) 使用setToMatched(friend1)findAndModify原子设置为&#34;匹配&#34 ;;如果friend1已匹配或不存在,则该方法将返回false;如果更新成功,则返回true

        friend1 如果isMatched(friend1)存在并且匹配则返回true,如果它不存在或存在则返回false并且&#34;不匹配&#34 ;

        friend1

答案 1 :(得分:1)

我仍然不清楚你的匹配系统的细节,但我可以给你一些一般指导。

从根本上说,如果没有原子读 - 修改 - 写功能,则无法同步进程。我不会解释如何从数据库中获取它,因为它从简单(具有事务隔离的SQL数据库)到不可能(某些NoSQL数据库)不等。如果您无法从数据库中获取它,那么您别无选择,只能在内存中进行同步。

其次,您需要能够同时从可用性池中自动删除两个匹配的人。但作为同一原子操作的一部分,您还需要在将它们彼此分配之前验证两者是否仍然可用。

第三,为了最大化吞吐量,您可以将系统设计为检测竞争条件,而不是阻止它们,并在检测到竞争时实施恢复过程。

上述所有内容在内存中比在数据库中更容易(和更高的性能)。如果可能的话,我会在记忆中做到这一点。

  1. 创建一个按插入顺序排列的内存匹配池,以便每个请求都知道之前发出的请求以及之后的请求。 (这不必反映请求的顺序,只需要将它们插入池中的顺序。)
  2. 请求进来。请求进入内存匹配池,数据库状态更改为“搜索”。
  3. 请求线程在内存池中搜索较旧的匹配请求
    1. 如果找到一个,那就是匹配。
    2. 如果未找到,则请求线程退出。
    3. 如果在搜索时,它与较新的请求匹配,则会停止搜索并让较新的请求将其从池中删除。
  4. 在匹配时,较新的请求通知旧请求停止搜索,并且从池中删除这两个请求。如果检测到比赛,则检测到比赛的人停止/撤消他们正在做的事情并根据新信息继续比赛。您必须设计竞争检测的顺序,以确保这种行为不会导致孤立匹配(相当于死锁),但这是完全可行的。
  5. 从池中删除匹配项后,将更新其数据库状态。
  6. 单独的工作线程按照从最旧到最新的顺序扫描队列,并删除过期的请求,使用新状态更新数据库。
  7. 在此系统中,唯一的阻塞同步操作正在插入匹配池并从匹配池中删除,这些是单独的锁。 (请求线程在从匹配池中删除其请求之前,必须获取一个锁,看它是否仍然在匹配池中,如果不是,则分支到种族恢复程序。)我认为这是理论上的你可以同步的极限。 (好吧,我猜你还必须在池已满时阻止插入池中,但你还能做什么?如果你可以创建一个新池,那么你可以扩展现有池。)

    请注意,通过对请求队列进行排序并按顺序搜索,您可以保证请求线程可以执行完整搜索。如果它找不到搜索,那么唯一的希望是以后的请求将匹配,并且后一个请求线程将找到该匹配。

答案 2 :(得分:0)

直到可以提出更好更简单的东西,我已经采用了超级简单的方法。处理BlockingQueue的单个后台线程。它没有很好的吞吐量,但提交者不必阻止。它还具有不需要在服务员的持久缓存上进行同步的优点。我可以很容易地将BlockingQueue更改为持久性支持的BlockingQueue。如果队列已满,提交者只需等待。

唯一的问题是,如果有很多提交者和轮询者,处理队列绝对落后于提交者。这是泵的简化实现。 match方法只是通过#getFriendIds进行迭代,并进行键控查找以查看该id的人是否存在于redis(或其他)缓存中。如果它们在缓存中,则它们是匹配的。我交换彼此的ID以匹配它们。

class HoldPump extends Thread {

    private final BlockingQueue<Incoming> queue = new ArrayBlockingQueue<>( CAPACITY );

    HoldPump() {
        super( "MatchingPump" );
    }

    public void submit( Person p ) {
        Incoming incoming = new Incoming( p.getId(), p.getFriendIds() ) );
        queueProcessing( incoming );
    }

    public void queueProcessing( Incoming incoming ) ... {
        queue.put( incoming );
    }

    @Override
    public void run() {
        try {
            while ( true ) {
                Incoming incoming = queue.take();
                tryMatch( incoming );
            }
        } catch ( InterruptedException e ) {
            Thread.interrupted();
        }
    }
}



protected void trytMatch( Incoming incoming ) {
    MatchStatus status = incoming.status;

    status.answer( incoming.holdDuration );

    for ( Integer candidate : incoming.candidates ) {
        MatchStatus waiting = waitingForMatchByPersonId.get( candidate );
        if ( waiting != null ) {
            waiting.setMatch( incoming.status.getPersonId() );
            status.setMatch( waiting.getPersonId() )
        }
    }
}

#setMatch方法实质上表示一个完成条件,它是MatchStatus中可重入锁定的一部分。

答案 3 :(得分:0)

  

我希望以异步方式执行此操作

异步处理=“人物提交者”和“人物匹配者”之间的逻辑队列对:

  • 匹配请求的一个队列
  • 匹配响应的另一个队列
  • 核心人匹配算法的工作方式相同,无论是同步还是异步
  • 换句话说,异步行为在顶部作为外观/装饰器添加,不会影响匹配设计。
  

有一些事情让我很难解决这个问题。一个是我不想在内存中有对象,我想让所有等待的候选人都使用redis或memcache或类似的东西。

  • 在跳转到解决方案之前,可能首先描述您的需求/约束。我会使用的语言类型: “我只有200MB的内存可用于匹配处理,并希望最大限度地提高性能。在任何时间点最多可以有10000个待处理的匹配项,并且在30分钟后匹配超时。”
  • 您不希望存储“在内存中”,而是“在缓存中”。但是你提到100%“内存”的两个缓存不是吗?我不明白他们添加了什么。您认为缓存将解决的任何特定要求?也许,作为一个例子,你可以在内存中存储200MB的大量数据,并拥有一个干净,简化的高性能算法。
  

我的第一个想法是在MatchServer中有一个Cache,它由redis之类的东西支持,并且对于当前被锁定和被操作的对象具有并发的弱值哈希映射。

  • 我建议您使用并发队列和vanilla哈希映射:请求者插入队列,匹配器线程从队列中拉出并插入到vanilla哈希映射中。
  • 并发哈希映射仅在您同时对其运行多个线程时才有用。如果只有一个线程在其上运行,那么一个vanilla哈希映射很好并且性能优越。虽然您可以让请求者直接插入并发哈希映射,但我认为这会产生太多的并发争用/锁定。
  • 这里你谈到锁定之前的锁定,但不一定需要这样做。

<强> 1。解决方案1:单线程中的序列化匹配处理

  • 在一个“匹配线程”中完成所有匹配
  • 无需 任何 锁定或交易 - 没有多线程争用
  • 并发队列序列化所有传入请求
  • 匹配线程一次处理一个请求,将新人插入哈希映射
  • 为新人运行匹配 - 如果找到匹配项,请链接两个人,从hashmap中删除每个人,并通过响应队列将结果返回给两个请求者

<强> 2。解决方案2:匹配处理的多个线程,悲观锁定具有最小关键区域

  • 获取对下一个人的引用(来自队列)。
  • 为该人运行匹配算法而不锁定(即乐观匹配处理)
  • 确定最佳候选匹配(如果有)
  • 然后做悲观的记录写作和存储:
    • 然后锁定该对
    • 确认它们在处于锁定状态后仍然不匹配
    • 将它们作为匹配链接,如果需要,更新到持久性商店
    • 解锁

第3。解决方案3:使用事务持久存储和* 乐观锁定进行匹配处理的多个线程 *

  • 修改人员类,添加匹配时间戳或版本号。这将充当乐观锁定控制标志。
  • 获取对下一个人的引用(来自队列,可选附加来自持久存储的“读取”)。
  • 为该人运行匹配算法而不锁定(即乐观匹配处理)
  • 确定最佳候选匹配(如果有)
  • 然后执行乐观记录写入和存储:
    • 启动事务(可能是JTA / DB事务),
    • 将两个人对象链接为匹配
    • 更新双人对象上的匹配时间戳/版本号(JPA通过@Version注释自动执行此操作)
    • 更新持久存储(例如DB),其中时间戳/版本号与值匹配(JPA通过@Version注释自动执行此操作)
    • 提交交易