Vertiefendes Verständnis von Netty FastThreadLocal

Autor: vivo Internet Server Team-Jiang Zhu


Dieser Artikel nimmt seltsame Online-Probleme als Ausgangspunkt, vergleicht die Implementierungslogik sowie die Vor- und Nachteile von JDK ThreadLocal und Netty FastThreadLocal und interpretiert den Quellcode eingehend, um Netty FastThreadLocal von der Tiefe bis zur Tiefe zu verstehen.


I. Einleitung


Ich habe kürzlich etwas über Netty erfahren und als ich das Netty FastThreadLocal-Kapitel sah, fiel mir ein seltsames Online-Problem ein.


Problembeschreibung : Wenn das Exportunternehmen Benutzerinformationen erhält, um festzustellen, ob https unterstützt wird, sind die erhaltenen Benutzerinformationen manchmal verwirrend.


Problemanalyse : Bei Verwendung von ThreadLocal zum Speichern von Benutzerinformationen konnte der Vorgang „remove()“ nicht rechtzeitig ausgeführt werden. Da der Tomcat-Arbeitsthread auf dem Thread-Pool basiert, erfolgt eine Thread-Wiederverwendung, sodass die erhaltenen Benutzerinformationen möglicherweise übrig bleiben vorheriger Thread.


Problembehebung : Entfernen Sie () sofort nach der Verwendung von ThreadLocal und führen Sie den Doppelversicherungsvorgang „Remove()“ aus, bevor Sie ThreadLocal verwenden.


Schauen wir uns als Nächstes JDK ThreadLocal und Netty FastThreadLocal genauer an.


2. Einführung in JDK ThreadLocal


ThreadLocal ist eine praktische Objektklasse, die von JDK bereitgestellt wird und in diesem Thread mit verschiedenen Methoden übergeben und abgerufen werden kann. Damit definierte Variablen sind nur in diesem Thread sichtbar, werden von anderen Threads nicht beeinflusst und sind von anderen Threads isoliert .


Wie wird dies erreicht? Wie in Abbildung 1 dargestellt, verfügt jeder Thread über eine ThreadLocalMap-Instanzvariable, die mithilfe von Lazy Loading erstellt wird. Sie wird erstellt, wenn der Thread zum ersten Mal auf diese Variable zugreift.


ThreadLocalMap verwendet eine lineare Erkennungsmethode zum Speichern von ThreadLocal-Objekten und den von ihnen verwalteten Daten. Die spezifische Operationslogik lautet wie folgt:

Angenommen, es gibt ein neues ThreadLocal-Objekt und der Speicherortindex, an dem es gespeichert werden soll, wird durch Hashing als x berechnet.


Zu diesem Zeitpunkt wird festgestellt, dass andere ThreadLocal-Objekte an der entsprechenden Position des Index x gespeichert wurden. Anschließend wird mit einer Schrittgröße von 1 rückwärts gesucht und der Index in x + 1 geändert.


Als nächstes wird festgestellt, dass andere ThreadLocal-Objekte an der Position gespeichert wurden, die dem Index x + 1 entspricht. Auf die gleiche Weise wird die Suche später fortgesetzt und der Index in x + 2 geändert.


Bis der Index x+3 gefunden wird, gilt er als frei, und dann werden das ThreadLocal-Objekt und seine verwalteten Daten zu einem Eintragsobjekt erstellt und am x+3-Speicherort gespeichert.


Wenn in ThreadLocalMap viele Daten vorhanden sind, kann es leicht zu Hash-Konflikten kommen. Um Konflikte zu lösen, ist eine kontinuierliche Abwärtsdurchquerung erforderlich. Die zeitliche Komplexität dieser Operation beträgt O(n) und die Effizienz ist gering .



Abbildung 1


Wie aus dem folgenden Code ersichtlich ist:

Der Eintragsschlüssel ist eine schwache Referenz , und der Wert ist eine starke Referenz. Während der JVM-Speicherbereinigung werden Objekte, auf die nur schwach verwiesen wird, unabhängig davon, ob der Speicher ausreicht, recycelt.


Wenn ThreadLocal jedoch nicht mehr verwendet und von GC recycelt wird, kann der Eintragsschlüssel in ThreadLocalMap NULL sein. Dann hat der Eintragswert immer einen starken Verweis auf die Daten und kann nicht freigegeben werden. Er kann nur auf den Thread warten zerstört werden, was zu einem Speicherverlust führt .

static class ThreadLocalMap {    // 弱引用,在资源紧张的时候可以回收部分不再引用的ThreadLocal变量    static class Entry extends WeakReference<ThreadLocal<?>> {        // 当前ThreadLocal对象所维护的数据        Object value;         Entry(ThreadLocal<?> k, Object v) {            super(k);            value = v;        }    }    // 省略其他代码}

Zusammenfassend lässt sich sagen, dass das vom JDK bereitgestellte ThreadLocal möglicherweise eine geringe Effizienz und Probleme mit Speicherverlusten aufweist. Warum also nicht eine entsprechende Optimierung und Transformation durchführen?

1. Der ThreadLocal-Klassenanmerkung nach zu urteilen, wurde sie in der JDK1.2-Version eingeführt. In den frühen Tagen wurde der Leistung des Programms möglicherweise nicht viel Aufmerksamkeit geschenkt.


2. In den meisten Multithread-Szenarien gibt es nur wenige ThreadLocal-Variablen im Thread, sodass die Wahrscheinlichkeit von Hash-Konflikten relativ gering ist. Wenn gelegentlich Hash-Konflikte auftreten, sind die Auswirkungen auf die Programmleistung relativ gering.


3. In Bezug auf das Speicherverlustproblem hat ThreadLocal selbst bestimmte Schutzmaßnahmen ergriffen. Wenn als Benutzer ein ThreadLocal-Objekt in einem Thread nicht mehr verwendet wird oder eine Ausnahme auftritt, rufen Sie sofort die Methode „remove()“ auf, um das Entry-Objekt zu löschen und gute Codierungsgewohnheiten zu entwickeln.


3. Einführung in Netty FastThreadLocal


FastThreadLocal ist eine optimierte Version von ThreadLocal, die vom JDK in Netty bereitgestellt wird. Dem Namen nach zu urteilen, sollte es schneller als ThreadLocal sein, um Szenarien zu bewältigen, in denen Netty große Parallelität und Datendurchsatz verarbeitet.


Wie wird dies erreicht? Wie in Abbildung 2 dargestellt, verfügt jeder Thread über eine Instanzvariable „InternalThreadLocalMap“.

Wenn jede FastThreadLocal-Instanz erstellt wird, wird AtomicInteger verwendet, um eine sequentielle Inkrementierung sicherzustellen, um einen eindeutigen Indexindex zu generieren. Dies ist der Ort, an dem die vom FastThreadLocal-Objekt verwalteten Daten gespeichert werden sollen.


Beim Lesen und Schreiben von Daten wird der Speicherort von FastThreadLocal direkt über den Index von FastThreadLocal ermittelt. Die zeitliche Komplexität beträgt O (1) und die Effizienz ist hoch.


Wenn der Indexindex sehr groß wird, wird auch das von InternalThreadLocalMap verwaltete Array sehr groß, sodass FastThreadLocal die Lese- und Schreibleistung durch den Austausch von Raum gegen Zeit verbessert.



Figur 2


4. Netty FastThreadLocal-Quellcode-Analyse


4.1 Bauweise

public class FastThreadLocal<V> {    // FastThreadLocal中的index是记录了该它维护的数据应该存储的位置    // InternalThreadLocalMap数组中的下标, 它是在构造函数中确定的    private final int index;     public InternalThreadLocal() {        index = InternalThreadLocalMap.nextVariableIndex();    }    // 省略其他代码}


public final class InternalThreadLocalMap extends UnpaddedInternalThreadLocalMap {    // 自增索引, ⽤于计算下次存储到Object数组中的位置    private static final AtomicInteger nextIndex = new AtomicInteger();     private static final int ARRAY_LIST_CAPACITY_MAX_SIZE = Integer.MAX_VALUE - 8;     public static int nextVariableIndex() {        int index = nextIndex.getAndIncrement();        if (index >= ARRAY_LIST_CAPACITY_MAX_SIZE || index < 0) {            nextIndex.set(ARRAY_LIST_CAPACITY_MAX_SIZE);            throw new IllegalStateException("too many thread-local indexed variables");        }        return index;    }    // 省略其他代码}


Die beiden oben genannten Codeteile wurden bereits in der Einführung zu Netty FastThreadLocal erläutert und werden daher hier nicht wiederholt.


4.2 Get-Methode


public class FastThreadLocal<V> {    // FastThreadLocal中的index是记录了该它维护的数据应该存储的位置    private final int index;     public final V get() {        // 获取当前线程的InternalThreadLocalMap        InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get();        // 根据当前线程的index从InternalThreadLocalMap中获取其绑定的数据        Object v = threadLocalMap.indexedVariable(index);        // 如果获取当前线程绑定的数据不为缺省值UNSET,则直接返回;否则进行初始化        if (v != InternalThreadLocalMap.UNSET) {            return (V) v;        }         return initialize(threadLocalMap);    }    // 省略其他代码}


public final class InternalThreadLocalMap extends UnpaddedInternalThreadLocalMap {    private static final int INDEXED_VARIABLE_TABLE_INITIAL_SIZE = 32;     // 未赋值的Object变量(缺省值),当⼀个与线程绑定的值被删除之后,会被设置为UNSET    public static final Object UNSET = new Object();     // 存储绑定到当前线程的数据的数组    private Object[] indexedVariables;     // slowThreadLocalMap为JDK ThreadLocal存储InternalThreadLocalMap    private static final ThreadLocal<InternalThreadLocalMap> slowThreadLocalMap =            new ThreadLocal<InternalThreadLocalMap>();     // 从绑定到当前线程的数据的数组中取出index位置的元素    public Object indexedVariable(int index) {        Object[] lookup = indexedVariables;        return index < lookup.length? lookup[index] : UNSET;    }     public static InternalThreadLocalMap get() {        Thread thread = Thread.currentThread();        // 判断当前线程是否是FastThreadLocalThread类型        if (thread instanceof FastThreadLocalThread) {            return fastGet((FastThreadLocalThread) thread);        } else {            return slowGet();        }    }     private static InternalThreadLocalMap fastGet(FastThreadLocalThread thread) {        // 直接获取当前线程的InternalThreadLocalMap        InternalThreadLocalMap threadLocalMap = thread.threadLocalMap();        // 如果当前线程的InternalThreadLocalMap还未创建,则创建并赋值        if (threadLocalMap == null) {            thread.setThreadLocalMap(threadLocalMap = new InternalThreadLocalMap());        }        return threadLocalMap;    }     private static InternalThreadLocalMap slowGet() {        // 使用JDK ThreadLocal获取InternalThreadLocalMap        InternalThreadLocalMap ret = slowThreadLocalMap.get();        if (ret == null) {            ret = new InternalThreadLocalMap();            slowThreadLocalMap.set(ret);        }        return ret;    }     private InternalThreadLocalMap() {        indexedVariables = newIndexedVariableTable();    }     // 初始化一个32位长度的Object数组,并将其元素全部设置为缺省值UNSET    private static Object[] newIndexedVariableTable() {        Object[] array = new Object[INDEXED_VARIABLE_TABLE_INITIAL_SIZE];        Arrays.fill(array, UNSET);        return array;    }    // 省略其他代码}


Die get()-  Methode im Quellcode  ist hauptsächlich in die folgenden drei Schritte unterteilt:

通过InternalThreadLocalMap.get()方法获取当前线程的InternalThreadLocalMap。
根据当前线程的index 从InternalThreadLocalMap中获取其绑定的数据。
如果不是缺省值UNSET,直接返回;如果是缺省值,则执行initialize方法进行初始化。


Lassen Sie uns weiter analysieren

Implementierungslogik der Methode InternalThreadLocalMap.get() .


首先判断当前线程是否是FastThreadLocalThread类型,如果是FastThreadLocalThread类型则直接使用fastGet方法获取InternalThreadLocalMap,如果不是FastThreadLocalThread类型则使用slowGet方法获取InternalThreadLocalMap兜底处理。
兜底处理中的slowGet方法会退化成JDK原生的ThreadLocal获取InternalThreadLocalMap。
获取InternalThreadLocalMap时,如果为null,则会直接创建一个InternalThreadLocalMap返回。其创建过过程中初始化一个32位长度的Object数组,并将其元素全部设置为缺省值UNSET。


4.3 Set-Methode

public class FastThreadLocal<V> {    // FastThreadLocal初始化时variablesToRemoveIndex被赋值为0    private static final int variablesToRemoveIndex = InternalThreadLocalMap.nextVariableIndex();     public final void set(V value) {        // 判断value值是否是未赋值的Object变量(缺省值)        if (value != InternalThreadLocalMap.UNSET) {            // 获取当前线程对应的InternalThreadLocalMap            InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.get();            // 将InternalThreadLocalMap中数据替换为新的value            // 并将FastThreadLocal对象保存到待清理的Set中            setKnownNotUnset(threadLocalMap, value);        } else {            remove();        }    }     private void setKnownNotUnset(InternalThreadLocalMap threadLocalMap, V value) {        // 将InternalThreadLocalMap中数据替换为新的value        if (threadLocalMap.setIndexedVariable(index, value)) {            // 并将当前的FastThreadLocal对象保存到待清理的Set中            addToVariablesToRemove(threadLocalMap, this);        }    }     private static void addToVariablesToRemove(InternalThreadLocalMap threadLocalMap, FastThreadLocal<?> variable) {        // 取下标index为0的数据,用于存储待清理的FastThreadLocal对象Set集合中        Object v = threadLocalMap.indexedVariable(variablesToRemoveIndex);        Set<FastThreadLocal<?>> variablesToRemove;        if (v == InternalThreadLocalMap.UNSET || v == null) {            // 下标index为0的数据为空,则创建FastThreadLocal对象Set集合            variablesToRemove = Collections.newSetFromMap(new IdentityHashMap<FastThreadLocal<?>, Boolean>());            // 将InternalThreadLocalMap中下标为0的数据,设置成FastThreadLocal对象Set集合            threadLocalMap.setIndexedVariable(variablesToRemoveIndex, variablesToRemove);        } else {            variablesToRemove = (Set<FastThreadLocal<?>>) v;        }        // 将FastThreadLocal对象保存到待清理的Set中        variablesToRemove.add(variable);    }    // 省略其他代码}


public final class InternalThreadLocalMap extends UnpaddedInternalThreadLocalMap {    // 未赋值的Object变量(缺省值),当⼀个与线程绑定的值被删除之后,会被设置为UNSET    public static final Object UNSET = new Object();    // 存储绑定到当前线程的数据的数组    private Object[] indexedVariables;    // 绑定到当前线程的数据的数组能再次采用x2扩容的最大量    private static final int ARRAY_LIST_CAPACITY_EXPAND_THRESHOLD = 1 << 30;    private static final int ARRAY_LIST_CAPACITY_MAX_SIZE = Integer.MAX_VALUE - 8;     // 将InternalThreadLocalMap中数据替换为新的value    public boolean setIndexedVariable(int index, Object value) {        Object[] lookup = indexedVariables;        if (index < lookup.length) {            Object oldValue = lookup[index];            // 直接将数组 index 位置设置为 value,时间复杂度为 O(1)            lookup[index] = value;            return oldValue == UNSET;        } else { // 绑定到当前线程的数据的数组需要扩容,则扩容数组并数组设置新value            expandIndexedVariableTableAndSet(index, value);            return true;        }    }     private void expandIndexedVariableTableAndSet(int index, Object value) {        Object[] oldArray = indexedVariables;        final int oldCapacity = oldArray.length;        int newCapacity;        // 判断可进行x2方式进行扩容        if (index < ARRAY_LIST_CAPACITY_EXPAND_THRESHOLD) {            newCapacity = index;            // 位操作,提升扩容效率            newCapacity |= newCapacity >>>  1;            newCapacity |= newCapacity >>>  2;            newCapacity |= newCapacity >>>  4;            newCapacity |= newCapacity >>>  8;            newCapacity |= newCapacity >>> 16;            newCapacity ++;        } else { // 不支持x2方式扩容,则设置绑定到当前线程的数据的数组容量为最大值            newCapacity = ARRAY_LIST_CAPACITY_MAX_SIZE;        }        // 按扩容后的大小创建新数组,并将老数组数据copy到新数组        Object[] newArray = Arrays.copyOf(oldArray, newCapacity);        // 新数组扩容后的部分赋UNSET缺省值        Arrays.fill(newArray, oldCapacity, newArray.length, UNSET);        // 新数组的index位置替换成新的value        newArray[index] = value;        // 绑定到当前线程的数据的数组用新数组替换        indexedVariables = newArray;    }    // 省略其他代码}


Die set()- Methode im Quellcode   ist hauptsächlich in die folgenden drei Schritte unterteilt:


判断value是否是缺省值UNSET,如果value不等于缺省值,则会通过InternalThreadLocalMap.get()方法获取当前线程的InternalThreadLocalMap,具体实现3.2小节中get()方法已做讲解。
通过FastThreadLocal中的setKnownNotUnset()方法将InternalThreadLocalMap中数据替换为新的value,并将当前的FastThreadLocal对象保存到待清理的Set中。
如果等于缺省值UNSET或nullelse的逻辑),会调用remove()方法,remove()具体见后面的代码分析。


Werfen wir als nächstes einen Blick darauf

Implementierungslogik der Methode InternalThreadLocalMap.setIndexedVariable .

Bestimmen Sie, ob der Index die Länge des Arrays indexedVariables überschreitet, das an den aktuellen Thread gebundene Daten speichert. Wenn nicht, rufen Sie die Daten an der Indexposition ab und legen Sie einen neuen Wert für die Indexpositionsdaten des Arrays fest.


Wenn der Grenzwert überschritten wird und das an die Daten des aktuellen Threads gebundene Array erweitert werden muss, wird das Array erweitert und die Daten an seiner Indexposition werden auf einen neuen Wert gesetzt.


Das erweiterte Array wird basierend auf dem Index erweitert und die erweiterte Kapazität des Arrays wird auf die Potenz von 2 aufgerundet. Kopieren Sie dann den Inhalt des ursprünglichen Arrays in das neue Array, füllen Sie den leeren Teil mit dem Standardwert UNSET und weisen Sie schließlich das neue Array indexedVariables zu.


Schauen wir weiter

Implementierungslogik der FastThreadLocal.addToVariablesToRemove- Methode.

1. Rufen Sie die Daten mit Index 0 ab (wird zum Speichern des zu bereinigenden FastThreadLocal-Objektsatzes verwendet). Wenn die Daten den Standardwert UNSET oder Null haben, wird ein FastThreadLocal-Objektsatz erstellt und der Satz wird an der Array-Position mit gefüllt Indexindex 0.


2.如果该数据不是缺省值UNSET,说明Set集合已金被填充,直接强转获取该Set集合。


3.最后将FastThreadLocal对象保存到待清理的Set集合中。


4.4 remove、removeAll方法

public class FastThreadLocal<V> {    // FastThreadLocal初始化时variablesToRemoveIndex被赋值为0    private static final int variablesToRemoveIndex = InternalThreadLocalMap.nextVariableIndex();     public final void remove() {        // 获取当前线程的InternalThreadLocalMap        // 删除当前的FastThreadLocal对象及其维护的数据        remove(InternalThreadLocalMap.getIfSet());    }     public final void remove(InternalThreadLocalMap threadLocalMap) {        if (threadLocalMap == null) {            return;        }         // 根据当前线程的index,并将该数组下标index位置对应的值设置为缺省值UNSET        Object v = threadLocalMap.removeIndexedVariable(index);        // 存储待清理的FastThreadLocal对象Set集合中删除当前FastThreadLocal对象        removeFromVariablesToRemove(threadLocalMap, this);         if (v != InternalThreadLocalMap.UNSET) {            try {                // 空方法,用户可以继承实现                onRemoval((V) v);            } catch (Exception e) {                PlatformDependent.throwException(e);            }        }    }     public static void removeAll() {        InternalThreadLocalMap threadLocalMap = InternalThreadLocalMap.getIfSet();        if (threadLocalMap == null) {            return;        }         try {            // 取下标index为0的数据,用于存储待清理的FastThreadLocal对象Set集合中            Object v = threadLocalMap.indexedVariable(variablesToRemoveIndex);            if (v != null && v != InternalThreadLocalMap.UNSET) {                @SuppressWarnings("unchecked")                Set<FastThreadLocal<?>> variablesToRemove = (Set<FastThreadLocal<?>>) v;                // 遍历所有的FastThreadLocal对象并删除它们以及它们维护的数据                FastThreadLocal<?>[] variablesToRemoveArray =                        variablesToRemove.toArray(new FastThreadLocal[0]);                for (FastThreadLocal<?> tlv: variablesToRemoveArray) {                    tlv.remove(threadLocalMap);                }            }        } finally {            // 删除InternalThreadLocalMap中threadLocalMap和slowThreadLocalMap数据            InternalThreadLocalMap.remove();        }    }     private static void removeFromVariablesToRemove(            InternalThreadLocalMap threadLocalMap, FastThreadLocal<?> variable) {        // 取下标index为0的数据,用于存储待清理的FastThreadLocal对象Set集合中        Object v = threadLocalMap.indexedVariable(variablesToRemoveIndex);         if (v == InternalThreadLocalMap.UNSET || v == null) {            return;        }         @SuppressWarnings("unchecked")        // 存储待清理的FastThreadLocal对象Set集合中删除该FastThreadLocal对象        Set<FastThreadLocal<?>> variablesToRemove = (Set<FastThreadLocal<?>>) v;        variablesToRemove.remove(variable);    }     // 省略其他代码}


public final class InternalThreadLocalMap extends UnpaddedInternalThreadLocalMap {     // 根据当前线程获取InternalThreadLocalMap       public static InternalThreadLocalMap getIfSet() {        Thread thread = Thread.currentThread();        if (thread instanceof FastThreadLocalThread) {            return ((FastThreadLocalThread) thread).threadLocalMap();        }        return slowThreadLocalMap.get();    }     // 数组下标index位置对应的值设置为缺省值UNSET    public Object removeIndexedVariable(int index) {        Object[] lookup = indexedVariables;        if (index < lookup.length) {            Object v = lookup[index];            lookup[index] = UNSET;            return v;        } else {            return UNSET;        }    }     // 删除threadLocalMap和slowThreadLocalMap数据    public static void remove() {        Thread thread = Thread.currentThread();        if (thread instanceof FastThreadLocalThread) {            ((FastThreadLocalThread) thread).setThreadLocalMap(null);        } else {            slowThreadLocalMap.remove();        }    }    // 省略其他代码}


源码中 remove() 方法主要分为下面2个步骤处理:

通过InternalThreadLocalMap.getIfSet()获取当前线程的InternalThreadLocalMap。具体和3.2小节get()方法里面获取当前线程的InternalThreadLocalMap相似,这里就不再重复介绍了。
删除当前的FastThreadLocal对象及其维护的数据。


源码中 removeAll() 方法主要分为下面3个步骤处理:

通过InternalThreadLocalMap.getIfSet()获取当前线程的InternalThreadLocalMap。
取下标index为0的数据(用于存储待清理的FastThreadLocal对象Set集合),然后遍历所有的FastThreadLocal对象并删除它们以及它们维护的数据。
最后会将InternalThreadLocalMap本身从线程中移除。


五、总结


那么使用ThreadLocal时最佳实践又如何呢?

 每次使用完ThreadLocal实例,在线程运行结束之前的finally代码块中主动调用它的remove()方法,清除Entry中的数据,避免操作不当导致的内存泄漏。


使⽤Netty的FastThreadLocal一定比JDK原生的ThreadLocal更快吗?

不⼀定。当线程是FastThreadLocalThread,则添加、获取FastThreadLocal所维护数据的时间复杂度是 O(1),⽽使⽤ThreadLocal可能存在哈希冲突,相对来说使⽤FastThreadLocal更⾼效。但如果是普通线程则可能更慢。


使⽤FastThreadLocal有哪些优点?

正如文章开头介绍JDK原生ThreadLocal存在的缺点,FastThreadLocal全部优化了,它更⾼效、而且如果使⽤的是FastThreadLocal,它会在任务执⾏完成后主动调⽤removeAll⽅法清除数据,避免潜在的内存泄露。



END

猜你喜欢


本文分享自微信公众号 - vivo互联网技术(vivoVMIC)。
如有侵权,请联系 [email protected] 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

雷军:小米全新操作系统澎湃 OS 正式版已完成封包 国美 App 抽奖页面弹窗辱骂其创始人 美国政府限制向中国出口 NVIDIA H800 GPU 小米澎湃OS界面曝光 大神用 Scratch 手搓 RISC-V 模拟器,成功运行 Linux 内核 RustDesk 远程桌面 1.2.3 发布,增强 Wayland 支持 拔出罗技 USB 接收器后,Linux 内核竟然崩溃了 DHH 锐评“打包工具”:前端根本不需要构建 (No Build) JetBrains 推出 Writerside,创建技术文档的工具 Node.js 21 正式发布
{{o.name}}
{{m.name}}

Ich denke du magst

Origin my.oschina.net/vivotech/blog/10120430
Empfohlen
Rangfolge