2024年Clojure现状调查!分享您的想法。

欢迎!请参阅关于页面了解有关工作原理的更多信息。

+14
Java Interop
已关闭

在此提问是因为我无法在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请求,添加了一个未实现的“虚拟”promise,并使`delay`主体变为`(deref p <timeout> nil)`。低值`<timeout>`—1-10毫秒—几乎从不显示任何问题。更高的值会越来越频繁地导致“虚拟线程饥饿”。

通过使用`ReentrantLock`自定义`Delay`实现来重写`clojure.lang.Delay`修复了这个问题。很高兴分享我们拥有的Java类—它可以在平台虚拟线程上通过 https://github.com/clojure/clojure/blob/master/test/clojure/test_clojure/delays.clj。 (它还有一个优化,使得它可以在可能的情况下避免在`deref`中引用`volatile`值字段。)
关于虚拟线程中synchronized的主要问题在于它围绕阻塞进行同步,所以在这个地方延迟确实可能是个问题。我已经检查了核心中的所有同步操作,确实只有一些看起来有问题(这是其中之一)。

"如果可能的话,避免在`deref`上引用`volatile`值字段,而是引用未同步的一个",这听起来像是在Java中常见的并发错误(类似于双重检查锁定),但可能根据用法而定。只是记住,没有保证任何其他线程会看到共享的非`volatile`、非同步状态的变化。
同意 `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) {
      // Volatile read
      Object v = value;

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

          // 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`是一个很有趣的建议,值得探索——谢谢!
我想了解原始的problematic代码做了什么。比如“http响应的后处理”本身是否在执行更多的IO?比如分发请求?
我们对虚拟线程的使用场景,正如你所猜测的,是I/O。(我说“曾经”,因为虚拟线程——按照我们的使用方式——太不可靠了。它们会导致调试代价高昂的死锁,可能耗去我们整整一个月的开发时间,包括所有在内。我们之后转而使用基于大线程池的自定义`future`宏。)我们使用`http-kit`进行HTTP请求广播,并对响应进行后处理。我们希望所有的操作都能达到1)在2)不耗尽`clojure.core/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`(参阅Clojure Q&A线程),这会锁定虚拟线程。`deref`延迟对象会使调用虚拟线程等待HTTP请求和后处理执行所需的时间,在某些情况下可能长达30秒。我们在这里不仅看到了锁定行为,还有死锁,这是出乎意料的。(锁定是`clojure.lang.Delay`的问题;死锁是虚拟线程的JVM底层实现的问题,至少据我所知。我相信在bugs.openjdk.org上有关于此的开放问题。为了避免锁定,我们使用了基于我们的自定义`ReentrantLock`的`delay`。然而,还有其他锁定问题,例如在JSON反序列化库中,似乎尽管`delay`有增强,但我们还是遇到了死锁区域。

请告诉我这些是否回答了你的问题!很高兴进一步说明。
by
关于看似是JVM中的一个错误的死锁问题,在JDK 21中是否已经解决,还是这只是JDK 19虚拟线程预览版中的错误?
...