能否为Java虚拟线程的载体线程创建ThreadLocal?

12

JEP-425: Virtual Threads指出,每个应用程序任务都应该创建一个新的虚拟线程,并且两次提到了在JVM中运行“数百万”虚拟线程的可能性。

同样的JEP意味着每个虚拟线程将可以访问其自己的线程本地值:

虚拟线程支持线程本地变量[...]就像平台线程一样,因此它们可以运行使用线程本地变量的现有代码。

线程本地变量经常用于缓存不是线程安全且创建成本高昂的对象。JEP警告:

但是,由于虚拟线程可能非常多,因此在仔细考虑后再使用线程本地变量。

确实很多!特别是考虑到虚拟线程没有池化(或者至少不应该)。作为短暂任务的代表,在虚拟线程中使用线程本地变量来缓存昂贵的对象似乎是毫无意义的。除非!我们可以从虚拟线程创建并访问绑定到其载体线程的线程本地变量

为了澄清,我想从这样的东西开始(当仅使用大小限制为池的本机线程时,这是完全可以接受的,但是当连续重新创建数百万个虚拟线程时,这显然不再是一种非常有效的缓存机制:)
static final ThreadLocal<DateFormat> CACHED = ThreadLocal.withInitial(DateFormat::getInstance);

对于这个(哎呀,这个类不是公共API的一部分):

static final ThreadLocal<DateFormat> CACHED = new jdk.internal.misc.CarrierThreadLocal();
// CACHED.set(...)

在我们深入讨论之前,必须先问一下,这是一个安全的做法吗?
据我所了解,虚拟线程仅仅是在平台线程上执行的逻辑阶段(也称为“载体线程”),具有卸载而不是被阻塞等待的能力。因此,我认为——如果我错了,请纠正我——1)虚拟线程永远不会被同一载体线程上的另一个虚拟线程交错或重新调度到另一个载体线程上,除非代码本来就会被阻塞;2)如果我们在缓存对象上调用的操作从不阻塞,则任务/虚拟线程将在同一载体上从头到尾运行,因此,在平台线程本地缓存对象是安全的。
冒昧回答自己的问题,JEP-425表明这是不可能的:
“载体的线程局部变量对虚拟线程不可用,反之亦然。”
我找不到公共API来获取载体线程或在平台线程上显式分配线程局部变量[从虚拟线程中],但这并不意味着我的研究是权威的。也许有其他方法?
然后我读到了JEP-429:作用域值,乍一看似乎是Java神之一击,以彻底摆脱ThreadLocal,或者至少为虚拟线程提供替代方案。事实上,该JEP使用"迁移到作用域值"等措辞,并表示它们"比线程本地变量更受欢迎,特别是在使用大量虚拟线程时"。
对于JEP中讨论的所有用例,我都只能同意。但是,在这份文件的底部,我们还发现了这一段:

有一些情况更适合于线程本地变量。例如,缓存昂贵且具有使用价值的对象,例如java.text.DateFormat的实例。众所周知,DateFormat对象是可变的,因此如果不进行同步,不能在线程之间共享。通过使用持续存在于线程生命周期中的线程本地变量,为每个线程分配自己的DateFormat对象通常是一种实用的方法。

考虑到早先讨论的内容,使用线程本地变量可能是"实用"但并不理想。事实上,JEP-429本身就开始于一个非常显著的言论:"如果每个百万虚拟线程都有可变的线程本地变量,则内存占用可能非常大"。

总结一下:

您是否找到了一种从虚拟线程中分配线程本地变量到载体线程的方法?

如果没有,那么可以说对于使用虚拟线程的应用程序来说,在线程本地缓存对象的做法已经死亡,而且必须实现/使用不同的方法,例如并发缓存/映射/池/等等吗?

2个回答

13

你写道:

所以我假设 - 如果我错了请纠正我 -

  1. 虚拟线程将永远不会被同一载体线程上的另一个虚拟线程交错或重新调度到另一个载体线程上,除非代码本来就会阻塞,因此,如果
  2. 我们在缓存对象上调用的操作从不阻塞,那么任务/虚拟线程将在同一载体上从头到尾运行,因此,在平台线程本地缓存对象是安全的。

但是Loom状态文档指出:

您不能对调度点的位置做任何假设,就像您对今天的线程一样。即使没有强制抢占,您调用的任何JDK或库方法都可能引入阻塞,因此会发生任务切换点。

更进一步

为此,我们计划使虚拟机支持在任何安全点强制预占执行的操作。如何将该功能暴露给调度程序尚未确定,并且可能不会出现在第一个预览版中。

因此,

  1. 虚拟线程只在即将被阻塞时才释放载体线程的假设,仅适用于当前预览。虚拟线程之间的抢占式切换是允许的,甚至计划在未来实现。

  2. 即使我们假设虚拟线程只能在执行阻塞操作时才释放载体线程,我们也无法预测何时会发生阻塞操作。

    • 我们无法控制的操作之一是类加载。加载类数据是一个阻塞操作,并且常见的JVM实现采用惰性方式进行类加载。甚至可能出现多次调用的方法突然执行未使用过的类的不常见路径的情况。

    • 另一个例子是资源加载。即使是像您的DateFormat这样简单的示例,也涉及以未指定的方式组织的资源、时区数据或本地化的月份和星期几名称。

所以,没有办法拥有一个安全工作的载体本地缓存,你的假设是使用线程本地(或类似)进行缓存已经过时了。你可以使用对象池,但由于这意味着某种形式的同步,因此您可能考虑只使用单个DateFormat¹并对其进行同步。这将实现您最初的想法,在使用对象期间不释放载体线程。

当然,在这个特定的例子中,更好的选择是使用来自java.time API的DateTimeFormatter,它是线程安全的,因此允许所有线程共享单个实例。

¹ 或多个被选中的方式,不涉及同步


哇,Holger的回答真是太棒了。今天我学到了新东西,我相信你的回答会推动更多开发者长期提升他们的技能!如果我可以给这个回答点赞五次,我一定会这么做。 - Martin Andersson

1
老实说,我不确定我是否正确理解了你的问题。 JEP 429表明:
  • "每个线程本地变量都是可变的","线程本地变量充当一种隐藏的方法参数",但很明显,"这可能会导致像意大利面条一样的数据流",建议采用更多的纪律和工程努力。一些最佳实践或更多的限制可能会有所帮助...
  • "...如果每个百万个虚拟线程都有可变的线程本地变量,则内存占用量可能很大。"这引出了一个问题,即应如何设计算法的实现,以利用虚拟线程的并行化。
  • "Java平台应该提供一种方式来维护可继承且不可变的每个线程数据,以供数千或数百万个虚拟线程使用。"这是一个可以理解的需求,但似乎作者们还不确定,需要进行更多的概念性工作。

现在回到你关于“从虚拟线程中的载体线程分配线程局部变量”的问题。为什么你需要这样做呢? 在某些情况下,在线程本地缓存对象是有意义的(例如,在访问数据库时缓存主体)。 由于虚拟线程相对较新(Java 19),宣布线程本地变量已死可能为时过早。


第一条我不明白。我的帖子并不是在谈论Scoped Values可以用于什么?它们可以直接替换掉我们过去使用的许多模式中的ThreadLocal,这非常好! - Martin Andersson
第二个要点我也不理解。实现什么算法??说实话,虚拟线程和JEP-428:结构化并发显著降低了Java中多线程编程的复杂性。 - Martin Andersson
回到你的问题上来...假设我们有这个所谓的“遗留”代码:ThreadLocal<DateFormat> CACHED = ThreadLocal.withInitial(DateFormat::getInstance)。一个遗留应用程序可能会有一个[本机]线程池,该线程池被限制为可用处理器的数量。因此,并不会分配太多这些昂贵的对象。同样的对象实例将在整个应用程序的生命周期内被重复使用,最有可能。随着“数百万”的短暂虚拟线程的出现和消失,每个线程都有自己的线程本地性...嗯,显然这些都不再是真的了。 - Martin Andersson
1
我认为的解决方案是将线程本地绑定到承载线程(我的发布的问题是询问是否可以使用JDK提供的API实现此功能),或者简单地停止使用线程本地来缓存对象,而改用另一种替代方案,例如对象池。 - Martin Andersson
3
好的,谢谢澄清。我不知道"将线程本地绑定到载体线程"是什么意思,对我来说,对象池/缓存似乎是合适的方式。也许我错了,但是"线程本地"这个名称难道不意味着本地变量与载体线程隔离吗?否则它就不是本地的,对吧? - Roland J.
1
我发布了一些示例代码以更好地理解问题,但被管理员删除了。所以恐怕我们无法继续这个有趣的话题了。 - Roland J.

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接