在Java 21中,引入了虚拟线程,这是一个非常非常重要的特性,之前一直苦苦寻找的Java协程,终于问世了。在高并发以及IO密集型的应用中,虚拟线程能极大的提高应用的性能和吞吐量。
## 什么是虚拟线程
先来看一下虚拟线程的概念。
### 虚拟线程概念
DK 21 引入了虚拟线程的支持,这是为了改善 Java 应用程序在高并发场景下的性能。虚拟线程是一种轻量级线程,**具有较小的内存占用,能够更高效地进行上下文切换,适用于 I/O 密集型的应用程序**。
### 虚拟线程的工作原理
当应用程序启动一个虚拟线程时,JVM会将这个虚拟线程交给JVM底层的线程池去执行,这个底层的线程池是一个传统线程池,并且真正执行虚拟线程中任务的线程,也是传统线程(操作系统线程)。当虚拟线程遇到阻塞时,JVM会立刻将虚拟线程挂起,让其它虚拟线程执行。也就是说,开启一个虚拟线程,并不需要启用一个传统线程,一般一个传统线程,可以执行多个虚拟线程的任务。在执行过程中,可以把虚拟线程理解成任务task。
这里举一个列子,假设用户创建了1000个虚拟线程,JVM的执行虚拟线程的线程池线程数是10,那么当第一个虚拟线程V1需要执行时,JVM会将V1调度到传统线程T1上,以此类推,虚拟线程V2会被调度到传统线程T2上,那么V3->T3,V4->T4,… V10->T10。当执行到V11时,这里有三种情况:
- 如果V1~V10中有任何一个线程遇到阻塞,我们这里假设V3遇到阻塞,那么JVM会将V3挂起,此时T3线程可用,那么V11被T3执行。
- 如果V1~V10没有线程被阻塞,那么JVM根据划分的时间片,假设每个虚拟线程允许执行100ns,那么过了100ns后,这里V1最新执行,JVM则将V1挂起,让T1去执行V11。
- 如果以上两种情况都不满足,那么先将V11挂起,等待有可用的传统线程时,再执行V11。
对于被阻塞的线程,如V3,当IO结束后,操作系统会通过事件,如epoll通知JVM,V3的IO操作已结束,此时JVM重新唤醒V3,选择可用的传统线程,来执行V3的任务。
这里需要注意两点:
- **虚拟线程IO执行完成后,会通过操作系统的事件通知机制,如epoll来通知JVM。**这一点对于虚拟线程的高效调度至关重要,因为它确保了 阻塞的 I/O 操作 不会占用操作系统线程的时间片,避免了传统线程池的高资源消耗和效率低下。。
- **JVM在对虚拟线程进行上下文切换时,因为不涉及到操作系统级别的线程上下文切换,代价非常低,速度也非常快。**
### 虚拟线程的调度
一般来说,程序员不需要对虚拟线程的调度进行管理,在JDK 21中,JVM默认启用了虚拟线程,并且会使用默认的ForkJoinPool线程池来执行虚拟线程,并且线程池的大小,也会根据虚拟线程的数量,进行动态调整。如果需要手动管理执行虚拟线程的线程池大小,那么需要自定义线程池,并将虚拟线程交给自定义的线程池来执行,这样虽然可行,通常没有必要。
### 虚拟线程与传统线程区别
虚拟线程与传统线程的区别主要在于:
- 创建虚拟线程时,JVM不会创建一个操作系统线程,创建一个传统线程时,JVM会创建一个操作系统线程。一个传统线程,可以轮询执行多个虚拟线程。
- 虚拟线程是由传统线程来执行的,虚拟线程的调度由JVM控制,传统线程的执行和调度,由操作系统来控制。
- 虚拟线程的上下文切换是由JVM控制的,因为不涉及到操作系统级别线程的上下文切换,虚拟线程上下文切换速度非常快,可以满足高并发需求。
- 创建一个虚拟线程占用的内存非常小,相对而言,创建一个传统线程,占用的内存空间大。在应用中,可以创建大量的虚拟线程,一般支持到百万级,而创建传统线程,一般只能到几千,我们一般也不建议创建这么多传统线程。
虚拟线程类似于task,传统系统与操作系统线程对应,一个传统线程可以执行多个虚拟线程。虚拟线程与task的区别是,当传统线程执行虚拟线程时,遇到阻塞会挂起虚拟线程,当传统线程执行task时,遇到阻塞就真的阻塞了。当然传统中的task继承自runnable,虚拟线程继承自Thread,他们属于不同的类,可调用的方法也不一样。
 
JDK也提供了虚拟线程池,可以通过下面方式得到一个虚拟线程池。
```java
import java.util.concurrent.*;
public class VirtualThreadPoolExample {
    public static void main(String[] args) {
        // 创建一个虚拟线程池
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        // 提交多个任务到线程池
        for (int i = 0; i < 10; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " running in " + Thread.currentThread());
            });
        }
        // 关闭线程池
        executor.shutdown();
    }
}
```
上面代码中,提交给线程池的任务,JVM都会为其创建一个虚拟线程,然后以虚拟线程的方式执行。
与传统的线程池相比,**虚拟线程池无法设置核心线程数、最大线程数、线程池大小、任务队列等参数,也不需要设置这些参数。**
 
虚拟线程与传统线程的相同之处:
- 他们都继承自Thread,用法一摸一样。也都支持线程池。
- 与传统一样,虚拟线程也有new,runnable,waiting,blocked,terminated等状态。
- 所有的锁,同步机制,对虚拟线程都适用,并且与传统线程一样,虚拟线程也会有资源争夺以及状态同步问题。并且也有上下文切换,虽然虚拟线程的上下文切换,代价非常小。
- 异常处理机制一样,如果遇到异常不处理,虚拟线程也会终止执行。
### 虚拟线程与协程的区别
协程是python中的异步编程技术,对于IO密集型应用,协程可以发挥很大的优势。协程的异步工作原理与虚拟线程相似,也是遇到IO就阻塞,让主线程继续执行其它任务,当IO完成时,操作系统通过事件机制,如epoll,通知python进程,产生一个事件,放到event loop队列中,最后由主线程执行。
虚拟线程与协程的主要区别在于:
| 区别   | 虚拟线程                                                                                  | 协程                                                                                      |
|--------|-------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
| 并发/并行 | 虚拟线程是并行的,多个虚拟线程可以同时在多个CPU上运行,同一时刻,可以运行多个虚拟线程。从这个角度将,虚拟线程能支持更高的并发。 | 协程不是并行的,因为只有一个主线程执行任务事件,同一时刻,只有一个任务被处理。                            |
| 资源争夺 | 虚拟线程中,存在资源争夺问题,以及状态同步问题,在编写代码时,需要考虑并发控制。甚至需要做合理的并发设计。 | 因为只有一个主线程在执行任务事件,没有并发问题,编程时也不需要考虑并发问题。                                  |
| 框架支持 | 虚拟线程是JDK 21的新特性,不需要任何框架支持。                                                  | 需要框架支持,写异步代码和同步代码,使用的是两个完全不同的框架,另外学习异步编程,增加了学习成本。并且异步编程有些难度,debug也变得复杂些。 |
## 怎样使用虚拟线程
在JDK 21中,使用虚拟线程有两种方式:
- 直接创建并启动虚拟线程。
```java
public class VirtualThreadExample {
    public static void main(String[] args) {
        Thread virtualThread = Thread.ofVirtual().start(() -> {
            System.out.println("Hello virtual thread ");
        });
        try {
            virtualThread.join();  // 等待虚拟线程完成
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
```
- 通过线程池执行虚拟线程。
```java
import java.util.concurrent.*;
public class VirtualThreadPoolExample {
    public static void main(String[] args) {
        // 创建一个虚拟线程池
        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
        // 提交多个任务到线程池
        for (int i = 0; i < 10; i++) {
            final int taskId = i;
            executor.submit(() -> {
                System.out.println("Task " + taskId + " running in " + Thread.currentThread());
            });
        }
        // 关闭线程池
        executor.shutdown();
    }
}
```
**通过线程池执行任务时,无法对并发实现控制,容易造成OOM,或耗尽服务方资源,可以自定义以下虚拟线程池,实现资源控制:**
```java
package com.zengbiaobiao.demo.vitrualthreaddemo;
import org.springframework.lang.NonNull;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
/*****
 * 虚拟线程池,支持配置任务队列数和最大并发任务数
 */
public class VirtualThreadExecutorService extends AbstractExecutorService {
    private volatile boolean shouldStop = false;
    private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
    private final Semaphore semaphore;
    private final BlockingQueue<Runnable> taskQueue;
    /******
     * 构造函数
     * @param taskQueueSize,任务队列大小,任务队列是一个阻塞队列,如果任务队列满了,那么调用execute方法会阻塞
     * @param concurrencySize,并发任务大小,同时执行的IO任务个数,防止并发过重,或者资源不够
     */
    public VirtualThreadExecutorService(int taskQueueSize, int concurrencySize) {
        this.semaphore = new Semaphore(concurrencySize);
        taskQueue = new LinkedBlockingQueue<>(taskQueueSize);
        this.loopEvent();
    }
    private void loopEvent() {
        Thread.ofVirtual().name("VirtualThreadExecutor").start(() -> {
            while (!shouldStop) {
                try {
                    Runnable task = taskQueue.take();
                    semaphore.acquire();
                    executor.execute(() -> {
                        try {
                            try {
                                task.run();
                            } finally {
                                semaphore.release();
                            }
                        } catch (Exception e) {
                            Thread.currentThread().interrupt();
                            throw new RuntimeException(e);
                        }
                    });
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    if (shouldStop) break;
                }
            }
        });
    }
    @Override
    public void shutdown() {
        shouldStop = true;
        executor.shutdown();
    }
    /**
     * @return The task not executed
     */
    @Override
    public List<Runnable> shutdownNow() {
        shouldStop = true;
        List<Runnable> remainingTasks = new ArrayList<>(taskQueue);
        taskQueue.clear();
        executor.shutdownNow();
        return remainingTasks;
    }
    @Override
    public boolean isShutdown() {
        return shouldStop;
    }
    @Override
    public boolean isTerminated() {
        return shouldStop && executor.isTerminated();
    }
    @Override
    public boolean awaitTermination(long timeout, TimeUnit unit) throws InterruptedException {
        return executor.awaitTermination(timeout, unit);
    }
    @Override
    public void execute(Runnable command) {
        try {
            taskQueue.put(command); // 阻塞直到队列有空间
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RejectedExecutionException("Task submission interrupted.", e);
        }
    }
}
```
测试代码如下:
```java
package com.zengbiaobiao.demo.vitrualthreaddemo;
import org.apache.tomcat.util.threads.VirtualThreadExecutor;
public class VirtualThreadExecutorServiceDemo {
    public static void main(String[] args) throws InterruptedException {
        VirtualThreadExecutorService executorService = new VirtualThreadExecutorService(10, 2);
        for (int i = 0; i < 100000; i++) {
            final String threadName = "thread-" + i;
            System.out.println(Thread.currentThread() + ": try to create task " + threadName);
            executorService.submit(() -> {
                System.out.println(Thread.currentThread() + ": " + threadName + " created!");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println(Thread.currentThread() + ": " + threadName + " finished!");
            });
        }
        Thread.sleep(5000000);
    }
}
```
## 哪些场景下可以应用虚拟线程
虚拟线程在IO密集型的高并发应用中能发挥出巨大的威力,在所有IO密集型应用中,具体来说,下列场景中,使用虚拟线程是比较合适的:
- 短时间需要完成的任务,且没有资源争夺或乱序问题,比如数据库写入,服务器 HTTP 请求处理,远程 RESTful API 调用,RabbitMQ 消息处理等应用场景。。
- 长时间运行的任务,但是对消息处理由顺序要求的任务。比如在电梯监控系统中,需要对每台电梯的数据进行处理,但是需要保证消息被处理的顺序。这时可以为每台电梯创建一个虚拟线程,这台电梯的数据交给专门的虚拟线程处理。因为应用中可以创建大量虚拟线程,并且虚拟线程一般都是异步处理任务,所以这个场景中,使用虚拟线程,可以满足高性能和高并发的要求。
- API网关中,对多个上游API数据进行查询,组装合并,使用虚拟线程,相比传统线程,效果更佳。虚拟线程,也支持CountDownLatch,Semaphore等工具类。
- 事件驱动的架构中,使用虚拟线程,效果也很好。比如spring boot中的异步事件,默认使用的是传统线程池,如果将其改成虚拟线程池,并发处理能力可以极大提高。
 
那么哪些场景下不合适使用虚拟线程呢?
- CPU密集型应用,比如大数据处理、图像处理、矩阵运算等。
- 如果应用有很高的并发资源争夺,或者状态同步,并且造成系统吞吐量低,需要考虑优化并发模型,这种场景下,不但传统线程不合适,虚拟线程也不合适。
## 虚拟线程实际应用场景举例
在一个spring boot项目中,有时候因为异步事件处理不过来,造成吞吐量下降,在JDK 21中,可以将事件改成虚拟线程来执行,代码如下:
```java
package com.zengbiaobiao.demo.vitrualthreaddemo;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
@Configuration
@EnableAsync
public class AsyncConfig {
    @Bean(name = "taskExecutor")
    public Executor taskExecutor() {
        // 最大并行任务数
        Semaphore semaphore = new Semaphore(100);
        ExecutorService virtualThreadPool = Executors.newVirtualThreadPerTaskExecutor();
        return runnable -> {
            try {
                // 控制并行任务数
                semaphore.acquire();
                virtualThreadPool.submit(() -> {
                    try {
                        runnable.run();
                    } finally {
                        semaphore.release();
                    }
                });
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                throw new RuntimeException("Task submission interrupted", e);
            }
        };
    }
}
```
事件发送和处理代码如下:
```java
package com.zengbiaobiao.demo.vitrualthreaddemo;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/home")
public class HomeController {
    private final ApplicationEventPublisher eventPublisher;
    public HomeController(ApplicationEventPublisher eventPublisher) {
        this.eventPublisher = eventPublisher;
    }
    @GetMapping("/index")
    public String index() {
        for (int i = 0; i < 1000; i++) {
            eventPublisher.publishEvent("event " + i);
        }
        return "success";
    }
    @EventListener
    @Async
    public void handleEvent(String event) {
        System.out.println(Thread.currentThread() + ": " + event);
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
}
```
输出结果如下:
```
VirtualThread[#2031]/runnable@ForkJoinPool-1-worker-4: event 976
VirtualThread[#2039]/runnable@ForkJoinPool-1-worker-1: event 980
VirtualThread[#1064]/runnable@ForkJoinPool-1-worker-1: event 983
VirtualThread[#2047]/runnable@ForkJoinPool-1-worker-2: event 984
VirtualThread[#2049]/runnable@ForkJoinPool-1-worker-9: event 985
VirtualThread[#2057]/runnable@ForkJoinPool-1-worker-2: event 989
VirtualThread[#2059]/runnable@ForkJoinPool-1-worker-3: event 990
VirtualThread[#2061]/runnable@ForkJoinPool-1-worker-6: event 991
VirtualThread[#2063]/runnable@ForkJoinPool-1-worker-10: event 992
VirtualThread[#2065]/runnable@ForkJoinPool-1-worker-10: event 993
VirtualThread[#2071]/runnable@ForkJoinPool-1-worker-3: event 996
VirtualThread[#2069]/runnable@ForkJoinPool-1-worker-2: event 995
VirtualThread[#2075]/runnable@ForkJoinPool-1-worker-7: event 998
VirtualThread[#2077]/runnable@ForkJoinPool-1-worker-10: event 999
```
上面输出结果中,每次并发执行100个任务,当虚拟线程池任务达到100之后,执行eventPublisher.publishEvent("event " + i)代码时,代码阻塞,过100ms之后,100个任务执行完成,下一批任务被执行。
## 虚拟线程使用注意事项
- 搞清楚任务类型,是IO密集型,还是CPU密集型
- 与传统线程结合使用
- **关注性能和资源,使用虚拟线程无法通过线程池等工具控制并发,需要借助Semepha,CountdownLatch等工具才能限流,如果不限流,容易造成OOM,或对目标系统造成巨大流量冲击。**
- **在异步框架中,关注隐藏的传统线程,**比如在HttpClient的异步请求中,每次异步请求都会创建一个HttpClient回调线程。大量的传统线程被间接创建,也容易引起OOM。
- 由synchronized关键字引起的pinned问题,看起来在JDK 21中,做了一些优化,即便虚拟线程pinned到传统线程,也只是性能退回到传统线程,无非是慢一点,反而不是太大问题。经过大量测试,发现基本只出现一次,之后不会再出现。不过使用ReentrantLock,效果确实会好很多,将synchronized关键字改成lock.()和lock.unlock(),ForkJoinPool中的线程数量会降低,并且任务分配均衡。
- 不要忽略软件设计,尤其在需要大量同步的应用中。
经过验证,**虚拟线程在遇到IO时,确实会让步,并且不消耗太多资源,核心特点是,让异步编程变得简单,并且不需要框架支持。但是容易因大的并发,造成OOM,或者对目标系统造成冲击,追求高并发可用,但一定要做测试和验证。**对于需要做状态同步,如需要加锁,或需要使用synchronize关键字的代码,需要优化设计,如果无法规避,那么,使用虚拟线程,和使用线程池,效果差不多。
虚拟线程存在的问题:
[Java Virtual Threads — some early gotchas to look out for](https://medium.com/@phil_3582/java-virtual-threads-some-early-gotchas-to-look-out-for-f65df1bad0db)
[Two Pitfalls by moving to Java Virtual Threads](https://blog.flowdirector.io/two-pitfalls-by-moving-to-java-virtual-threads-3246d6f5075d)
[Java 21 Virtual Threads - Dude, Where’s My Lock?](https://netflixtechblog.com/java-21-virtual-threads-dude-wheres-my-lock-3052540e231d)
[Pitfalls to avoid when switching to Virtual threads ](https://blog.ycrash.io/pitfalls-to-avoid-when-switching-to-virtual-threads/)
[Do Java 21 virtual threads address the main reason to switch to reactive single-thread frameworks?](https://stackoverflow.com/questions/78318131/do-java-21-virtual-threads-address-the-main-reason-to-switch-to-reactive-single)
[Pinning: A pitfall to avoid when using virtual threads in Java ](https://abhishekvrshny.medium.com/pinning-a-pitfall-to-avoid-when-using-virtual-threads-in-java-482c5eab78a3)
[Taming the Virtual Threads: Embracing Concurrency With Pitfall Avoidance ](https://dzone.com/articles/taming-the-virtual-threads-embracing-concurrency-w)
[Pitfalls you encounter with virtual threads ](https://davidvlijmincx.com/posts/pitfalls-of-virtual-threads/)
---
示例代码在[Gitee](https://gitee.com/zengbiaobiao/java-virtual-thread.git)上同步,你也可以访问[曾彪彪的个人博客](http://zengbiaobiao.com)点击查看作者更多文章

Java虚拟线程探索的更多相关文章

  1. 虚拟线程 - VirtualThread源码透视

    前提 JDK19于2022-09-20发布GA版本,该版本提供了虚拟线程的预览功能.下载JDK19之后翻看了一下有关虚拟线程的一些源码,跟早些时候的Loom项目构建版本基本并没有很大出入,也跟第三方J ...

  2. Java将增加虚拟线程,挑战Go协程

    我们知道 Go 语言最大亮点之一就是原生支持并发,这得益于 Go 语言的协程机制.一个 go 语句就可以发起一个协程 (goroutin).协程本质上是一种用户态线程,它不需要操作系统来进行调度,而是 ...

  3. Java的虚拟线程(协程)特性开启预览阶段,多线程开发的难度将大大降低

    高并发.多线程一直是Java编程中的难点,也是面试题中的要点.Java开发者也一直在尝试使用多线程来解决应用服务器的并发问题.但是多线程并不容易,为此一个新的技术出现了,这就是虚拟线程. 传统多线程的 ...

  4. Java SE 19 虚拟线程

    Java SE 19 虚拟线程 作者:Grey 原文地址: 博客园:Java SE 19 虚拟线程 CSDN:Java SE 19 虚拟线程 说明 虚拟线程(Virtual Threads)是在Pro ...

  5. Java多线程--线程安全问题的相关研究

    在刚刚学线程的时候我们经常会碰到这么一个问题:模拟火车站售票窗口售票.代码如下: package cn.blogs.com.isole; /* 模拟火车站售票窗口售票,假设有50张余票 */ publ ...

  6. Java的线程安全

    线程安全 我们这里讨论的线程安全,就限定于多个线程之间存在共享数据访问这个前提,因为如果一段代码根本不会与其他线程共享数据,那么从线程安全的角度来看,程序是串行执行还是多线程执行对它来说是完全没有区别 ...

  7. Java虚拟中内存分块

    Java虚拟机JVM(Java Virtual Machine)中内存分块 JAVA中通常分为5个区域虚拟机栈.堆.方法区.程序计数器.本地方法区.我们一般讲的是Java虚拟机管理的四个区域虚拟机栈. ...

  8. java thread 线程40个问题汇总

    http://www.codeceo.com/article/40-java-thread-problems.html 1.多线程有什么用? 一个可能在很多人看来很扯淡的一个问题:我会用多线程就好了, ...

  9. java 守护线程整理

    java中finally语句不走的可能存在system.exit(0)与守护线程 线程sleep采用TimeUnit类 设定线程的名字thread.getcurrentThread().setName ...

  10. Java多线程——线程的创建方式

    Java多线程——线程的创建方式 摘要:本文主要学习了线程的创建方式,线程的常用属性和方法,以及线程的几个基本状态. 部分内容来自以下博客: https://www.cnblogs.com/dolph ...

随机推荐

  1. vue父组件向子组件传递一个对象,使用一个对象绑定多个 prop

    如果你想要将一个对象的所有属性都当作 props 传入,你可以使用没有参数的 v-bind,即只使用 v-bind 而非 :prop-name.例如,这里有一个 post 对象: export def ...

  2. js之模块导入与导出:export、export default、module.exports、exports

    前两者export.export default可为一组,是es6的规范,和import匹配,import是es6中的语法标准:后两者module.exports.exports可为一组,是commo ...

  3. Lambda【1】-- List相关Lambda表达式使用(上篇)

    Lambda在jdk1.8里面已经很好用了,在这里不讲底层的实现,只有简单的用法,会继续补全. 首先一个list我们要使用lambda的话,需要使用它的stream()方法,获取流,才能使用后续的方法 ...

  4. CSS3 过渡和动画

    1.CSS过渡 含义:在没有过渡属性的时候,当一个元素的属性值发生变化时,浏览器就会将这个元素瞬间渲染成新属性值的样式.例如一个定位元素top:0,动态修改成top:100px,这个元素就瞬间跑到10 ...

  5. Skyvern – AI浏览器自动化测试工具

    Skyvern – AI浏览器自动化测试工具 ​​ ‍ Skyvern是什么 Skyvern是开源的浏览器自动化工具,结合大型语言模型(LLMs)和计算机视觉技术实现复杂的网页交互和数据提取.与传统的 ...

  6. Qt No Target Architecture

    在QT中引入processthreadsapi.h,如果出现 "No Target Architecture",需要在processthreadsapi.h前引入windows.h ...

  7. Gitbook的docker安装配置

    创建目录:/gitbook/gitbook 和 /gitbook/html /gitbook/gitbook目录下,touch新建README.md docker安装gitbook docker ru ...

  8. 重磅宣布|强强联合,腾讯云携手Veeam提供云上数据存储服务

    近日获悉,腾讯云对象存储COS正式通过Veeam备份软件标准化测试,携手为用户提供云上数据存储服务. Veeam对COS的支持是通过SOBR( Scale out backup repository) ...

  9. 使用 ASP.NET Core 5 Web API 创建可发现的 HTTP API

    使用 ASP.NET Core 5 Web API 创建可发现的 HTTP API https://devblogs.microsoft.com/aspnet/creating-discoverabl ...

  10. 搭建 zerotier 的行星服务

    放弃moon节点,直接搭建Zerotier根服务器_软件应用_什么值得买 Zerotier的优点在于其部署十分简便,只需在zerotier官网注册登陆并创建网络,在自己的设备安装客户端加入网络后,ze ...