请在《2024年Clojure现状调查!》中分享您的想法。

欢迎!有关如何工作的更多信息,请参阅关于页面。

+14
Java互操作
已关闭

在这里提问,因为我无法在https://clojure.atlassian.net/jira/software/c/projects/CLJ/issues/CLJ-2771上发言。

我运营一家初创公司,我们使用Java 19虚拟线程时遇到了这个synchronized块。

https://github.com/clojure/clojure/blob/4090f405466ea90bbaf3addbe41f0a6acb164dbb/src/jvm/clojure/lang/Delay.java#L35

由于-Djdk.tracePinnedThreads没有任何报告,因此调试这个问题花费了大量工程努力和耐心。

目前,我们需要重新实现一个对虚拟线程友好的(非synchronizedclojure.core/delay,以及尽可能多的clojure.core函数,以确保虚拟线程不会被固定。

一如既往,我对Clojure团队的成果感到震惊!只是想强调一些API对虚拟线程不够友好,如果可能的话,希望能优先处理,因为虚拟线程非常有用。

谢谢!

已关闭,备注: 在1.12.0-alpha5中完成

2 个回答

+2
 
最佳答案

Clojure 1.12.0-alpha5现可用,并修改了lazy-seq和delay以使用锁而不是synchronized。

0

谢谢,非常感兴趣为虚拟线程准备Clojure。我想知道你遇到的延迟是什么?

很高兴听到这个消息!最初是对HTTP响应(本身是HttpKit返回的Promise)进行后处理。然后为了消除一些变量,我去掉了HTTP请求,增加了一个未实现的“dummy” Promise,并将`delay`的主体简单改为`(deref p <timeout> nil)`。``的值较低(1-10 毫秒),几乎从未出现任何问题。较高的值会越来越经常地因“挂起”而导致“虚拟线程饥饿”。

通过使用带有`ReentrantLock`的自定义`Delay`实现重写`clojure.lang.Delay`解决了这个问题。我很乐意分享我们的Java类——它通过了https://github.com/clojure/clojure/blob/master/test/clojure/test_clojure/delays.clj在平台和虚拟线程上的测试。(它还有一项优化,避免了在`deref`中引用一个`volatile`值字段,如果能引用一个未同步的一个。)
在虚拟线程中,同步的问题主要是在阻塞时同步,所以围绕这个点延迟确实可能是一个问题。我检查了核心中的所有同步,其中只有几个看起来有问题(这就是其中之一)。

“在可能的情况下,避免在`deref`中引用一个`volatile`值字段,而应该引用一个未同步的一个”听起来像Java中的经典并发错误(类似双重检查锁定),但可能取决于使用情况。只需记住,没有保证任何其他线程都会看到共享的非`volatile`、非同步状态的更改。
by
同意关于 `synchronized` 的说法。

关于可能出现并发错误的观察很准确。以下是相关代码。特别是 `deref` 使用了 `volatile`/未同步策略

```
public class Delay implements IDeref, IPending {
  volatile Object value;
  Object unsynchronizedValue;
  ReentrantLock lock;

  private static class ExceptionalValue {
    private final Object v;

    public ExceptionalValue (Object v) {
      this.v = v;
    }
  }

  private static class PendingValue {
    private final Object v;

    public PendingValue (Object v) {
      this.v = v;
    }
  }

  public Delay(IFn f){
    Object v = new PendingValue(f);
    unsynchronizedValue = v;
    value = v;
    lock = new ReentrantLock();
  }

  private static Object getOrThrow(Object v) {
    if (v instanceof ExceptionalValue) {
      return Util.sneakyThrow((Throwable)(((ExceptionalValue)v).v));
    } else {
      return v;
    }
  }

  public Object deref() {
    if (unsynchronizedValue instanceof PendingValue) {
      %bf Volatile read
      Object v = value;

      if (v instanceof PendingValue) {
        try {
          lock.lock();

          %bf Volatile read after lock held, in case it changed
          Object vAfter = value;

          if (vAfter instanceof PendingValue) {
            IFn f = (IFn)(((PendingValue)vAfter).v);

            Object vComputed = null;
            try {
              vComputed = f.invoke();
            } catch (Throwable t) {
              vComputed = new ExceptionalValue(t);
            }

            unsynchronizedValue = vComputed;
            value = vComputed;
            return getOrThrow(vComputed);
          } else {
            return getOrThrow(vAfter);
          }
        } finally {
          lock.unlock();
        }
      } else {
        return v;
      }
    } else {
      return getOrThrow(unsynchronizedValue);
    }
  }

  ... // `force`, `isRealized` implemented here
}
```
您在锁下写入unsynchronizedValue,但读操作不在锁下,因此无法保证其他线程确实看到了那个写入。这是一个线程可见性问题,类似于经典的双重检查锁定模式。我不会尝试以这种方式作弊,但在这里使用ReentrantReadWriteLock可能是有用的。
您说得对 —— 不应该期望读者必须获取对`unsynchronizedValue`的更改。话虽如此,如果线程未能获取更改,则降级到读取`value`(它是`volatile`的),这保证了它是始终最新的。在我们的测试中,大约40%的读取在读取`value`之前捡到了`unsynchronizedValue`,因此在这个层面上(以及基于其他内部性能测试),该优化似乎是很有价值的。在所有情况下,`deref()`都产生了正确的结果。

探索`ReentrantReadWriteLock`是一个有趣的建议 —— 感谢!
我想了解原来的问题代码做了什么。是不是“http响应的后处理”本身运行了更多的IO?比如扩散请求?
我们对虚拟线程的使用案例,正如您所怀疑的,是I/O。我说“过去”,因为我们使用的虚拟线程太不可靠了。它们会导致难以调试的死锁,并可能浪费我们大约一个月的开发时间。自此以后,我们转向在大型线程池顶部使用自定义的`future`宏。

本质上,这是我们正在执行的代码
```clojure
;; 在一个库函数中
(defn request-and-post-process [request]
  (let [response (http-kit/request request)]
    ;; 使用`delay`来保持它是一个可解析的输出,同时避免使用线程
    (delay (post-process @response))))

;; 在一个业务逻辑函数中
;; 将在更高层被`deref`
(defn async-concurrently-make-requests [requests]
  (vthread
    (->> requests
        ;; 并发启动所有请求
        (mapv #(vthread @(request-and-post-process %)))
        ;; 聚合
        (mapv deref))))
```

请注意,这里使用了`clojure.core/delay`。事实上,`clojure.lang.Delay`在它的`deref`方法中使用了`synchronized`,这会导致虚拟线程延迟。解析一个`delay`会使调用虚拟线程等待HTTP请求和后处理运行所需的时间,在某些情况下可能需要30秒。我们不仅看到了这里的延迟行为,还看到了死锁,这是意外的。(延迟是一个`(clojure.lang.Delay)`的问题;死锁是虚拟线程底层的JVM实现的一个问题,据我所知。我相信在bugs.openjdk.org上有对此的开放工单。)为了避免延迟,我们使用了基于我们自定义的`ReentrantLock`的`delay`。然而,还有其他一些在例如一个JSON反序列化库中发生的延迟,尽管增加了`delay`增强,这似乎还是导致了死锁。

如果这一切都回答了您的问题!很高兴进一步阐述。
关于似乎在JVM中存在的一个死锁问题的bug,这个bug在JDK 21中解决了吗?还是这只是JDK 19虚拟线程预览中的一个bug?
...