并发编程(8)-线程池

    Java 中的线程池是运用场景最多的并发框架,几乎所有需要异步或并发执行任务的程序都可以使用线程池。在开发过程中,合理地使用线程池能够带来3个好处。
1、降低资源消耗。通过重复利用已创建的线程降低线程创建和销毁造成的消耗。
2、提高响应速度。当任务到达时,任务可以不需要等到线程创建就能立即执行。假设一个服务器完成一项任务所需时间为:T1 创建线程时间,T2 在线程中执行任务的时间,T3 销毁线程时间。如果:T1 + T3 远大于T2,则可以采用线程池,以提高服务器性能。线程池技术正是关注如何缩短或调整T1,T3 时间的技术,从而提高服务器程序性能的。它把T1,T3 分别安排在服务器程序的启动和结束的时间段或者一些空闲的时间段,这样在服务器程序处理客户请求时,不会有T1,T3 的开销了。
3、提高线程的可管理性。线程是稀缺资源,如果无限制地创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一分配、调优和监控。
    假设一个服务器一天要处理50000 个请求,并且每个请求需要一个单独的线程完成。在线程池中,线程数一般是固定的,所以产生线程总数不会超过线程池中线程的数目,而如果服务器不利用线程池来处理这些请求则线程总数为50000。一般线程池大小是远小于50000。所以利用线程池的服务器程序不会为了创建 50000 而在处理请求时浪费时间,从而提高效率。

ThreadPoolExecutor 的类关系

Executor 是一个接口,它是Executor 框架的基础,它将任务的提交与任务的执行分离开来。
ExecutorService 接口继承了Executor,在其上做了一些shutdown()、submit()的扩展,可以说是真正的线程池接口;
AbstractExecutorService 抽象类实现了ExecutorService 接口中的大部分方法;
ThreadPoolExecutor 是线程池的核心实现类,用来执行被提交的任务。
ScheduledExecutorService 接口继承了ExecutorService 接口,提供了带"周期执行"功能ExecutorService;
ScheduledThreadPoolExecutor 是一个实现类,可以在给定的延迟后运行命令,或者定期执行命令。ScheduledThreadPoolExecutor 比Timer 更灵活,功能更强大。

线程池的创建各个参数含义

public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)

corePoolSize

    线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize;
    如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;
    如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。

maximumPoolSize

    线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize

keepAliveTime

    线程空闲时的存活时间,即当线程没有任务执行时,继续存活的时间。默认情况下,该参数只在线程数大于corePoolSize 时才有用;

TimeUnit

    keepAliveTime 的时间单位
    workQueue:workQueue 必须是BlockingQueue 阻塞队列。当线程池中的线程数超过它的corePoolSize 的时候,线程会进入阻塞队列进行阻塞等待。通过workQueue,线程池实现了阻塞功能;

workQueue

    用于保存等待执行的任务的阻塞队列,一般来说,我们应该尽量使用有界队列,因为使用无界队列作为工作队列会对线程池带来如下影响。
    1)当线程池中的线程数达到corePoolSize 后,新任务将在无界队列中等待,因此线程池中的线程数不会超过corePoolSize。
    2)由于1,使用无界队列时maximumPoolSize 将是一个无效参数。
    3)由于1 和2,使用无界队列时keepAliveTime 将是一个无效参数。
    4)更重要的,使用无界queue 可能会耗尽系统资源,有界队列则有助于防止资源耗尽,同时即使使用有界队列,也要尽量控制队列的大小在一个合适的范围。
    所以我们一般会使用,ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue、PriorityBlockingQueue。

threadFactory

    创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名,当然还可以更加自由的对线程做更多的设置,比如设置所有的线程为守护线程。
 

public class Test {
    /**
     * 自定义线程工厂
     */
    public static class MyFactory implements ThreadFactory {
        AtomicLong atomicLong = new AtomicLong(0L);
        @Override
        public Thread newThread(Runnable r) {
            Thread thread = new Thread(r, "第"+atomicLong.getAndIncrement()+"个线程");
            thread.setDaemon(true);
            System.out.println("工厂创建"+thread.getName()+"。");
            return thread;
        }
    }

    public static class Child implements Callable<String>{
        private Random random;
        public Child(Random random){
            this.random = random;
        }
        @Override
        public String call() throws Exception {
            SleepTools.ms(random.nextInt(100)*3);
            return Thread.currentThread().getName()+"执行处理。";
        }
    }

    public static void main(String[] args) throws Exception{
        ExecutorService threadPool = new ThreadPoolExecutor(3, 5, 5, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(10),
                new MyFactory(), new ThreadPoolExecutor.DiscardOldestPolicy());

        Random random = new Random(3);

        ArrayList<Child> childrens = new ArrayList<>();
        for (int i = 0; i < 15; i++) {
            Child child = new Child(random);
            childrens.add(child);
        }

        List<Future<String>> futures = threadPool.invokeAll(childrens);
        System.out.println("-----------------");
        for (Future<String> future : futures) {
            System.out.println(future.get());
        }

    }
}

RejectedExecutionHandler

    线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4 种策略:
(1)AbortPolicy:直接抛出异常,默认策略;
(2)CallerRunsPolicy:用调用者所在的线程来执行任务;
(3)DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务;
(4)DiscardPolicy:直接丢弃任务;
    当然也可以根据应用场景实现RejectedExecutionHandler 接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务。

线程池的扩展

ExecutorService threadPool = new ThreadPoolExecutor(3, 5, 5, TimeUnit.SECONDS, new ArrayBlockingQueue<Runnable>(10),
                new MyFactory(), new ThreadPoolExecutor.DiscardOldestPolicy()){
            @Override
            protected void beforeExecute(Thread t, Runnable r) {
                System.out.println("before:"+t.getName());
            }

            @Override
            protected void afterExecute(Runnable r, Throwable t) {
                System.out.println("after:"+new Thread(r).getName());
            }

            @Override
            protected void terminated() {
                System.out.println("线程池退出 ");
            }
        };

线程池工作机制

    1)如果当前运行的线程少于corePoolSize,则创建新线程来执行任务(注意,执行这一步骤需要获取全局锁)。
    2)如果运行的线程等于或多于corePoolSize,则将任务加入BlockingQueue。
    3)如果无法将任务加入BlockingQueue(队列已满),则创建新的线程来处理任务。
    4)如果创建新线程将使当前运行的线程超出maximumPoolSize,任务将被拒绝,并调用RejectedExecutionHandler.rejectedExecution()方法。

提交任务

    execute() 方法:用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。
    submit() 方法:用于提交需要返回值的任务。线程池会返回一个 future 类型的对象,通过这个 future 对象可以判断任务是否执行成功,并且可以通过 future 的 get() 方法来获取返回值,get() 方法会阻塞当前线程直到任务完成,而使用 get(long timeout, TimeUnit unit) 方法则会阻塞当前线程一段时间后立即返回,这时候有可能任务没有执行完。

线程池的关闭

    可以通过调用线程池的 shutdown() 或 shutdownNow() 方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的 interrupt() 方法来中断线程,所以无法响应中断的任务可能永远无法终止。但是它们存在一定的区别:shutdownNow() 首先将线程池的状态设置成 STOP,然后尝试停止所有的正在执行或暂停任务的线程,并返回等待执行任务的列表;而 shutdown() 只是将线程池的状态设置成 SHUTDOWN 状态,然后中断所有没有正在执行任务的线程。
    只要调用了这两个关闭方法中的任意一个,isShutdown() 方法就会返回 true。当所有的任务都已关闭后,才表示线程池关闭成功,这时调用 isTerminaed() 方法会返回 true。至于应该调用哪一种方法来关闭线程池,应该由提交到线程池的任务特性决定,通常调用 shutdown() 方法来关闭线程池,如果任务不一定要执行完,则可以调用 shutdownNow() 方法。

线程池的配置

    要想合理地配置线程池,就必须首先分析任务特性,可以从以下几个角度来分析:
1、任务的性质:CPU 密集型任务、IO 密集型任务和混合型任务。
2、任务的优先级:高、中和低。
3、任务的执行时间:长、中和短。
4、任务的依赖性:是否依赖其他系统资源,如数据库连接。


性质不同的任务可以用不同规模的线程池分开处理。
    CPU 密集型任务应配置尽可能小的线程,如配置 Ncpu+1 个线程的线程池。
    IO 密集型任务线程并不是一直在执行任务,则应配置尽可能多的线程,如 2*Ncpu。
    混合型的任务,如果可以拆分,将其拆分成一个 CPU 密集型任务和一个 IO 密集型任务,只要这两个任务执行的时间相差不是太大,那么分解后执行的吞吐量将高于串行执行的吞吐量。如果这两个任务执行时间相差太大,则没必要进行分解。可以通过 Runtime.getRuntime().availableProcessors() 方法获得当前设备的 CPU 个数。


对于 IO 型的任务的最佳线程数,有个公式可以计算:
Nthreads = NCPU * UCPU * (1 + W/C)
其中:
a) NCPU 是处理器的核的数目
b) UCPU 是期望的CPU 利用率(该值应该介于0 和1 之间)
c) W/C 是等待时间与计算时间的比率;等待时间与计算时间可以在 Linux 下使用相关的 vmstat 命令或者 top 命令查看。


优先级不同的任务可以使用优先级队列 PriorityBlockingQueue 来处理。它可以让优先级高的任务先执行。
执行时间不同的任务可以交给不同规模的线程池来处理,或者可以使用优先级队列,让执行时间短的任务先执行。
依赖数据库连接池的任务,因为线程提交 SQL 后需要等待数据库返回结果,等待的时间越长,则 CPU 空闲时间就越长,那么线程数应该设置得越大,这样才能更好地利用 CPU。


    建议使用有界队列。有界队列能增加系统的稳定性和预警能力,可以根据需要设大一点儿,比如几千。
    假设,我们现在有一个 Web 系统,里面使用了线程池来处理业务,在某些情况下,系统里后台任务线程池的队列和线程池全满了,不断抛出抛弃任务的异常,通过排查发现是数据库出现了问题,导致执行 SQL 变得非常缓慢,因为后台任务线程池里的任务全是需要向数据库查询和插入数据的,所以导致线程池里的工作线程全部阻塞,任务积压在线程池里。
    如果当时我们设置成无界队列,那么线程池的队列就会越来越多,有可能会撑满内存,导致整个系统不可用,而不只是后台任务出现问题。

预定义线程池

FixedThreadPool 详解

    创建使用固定线程数的 FixedThreadPool 的API。适用于为了满足资源管理的需求,而需要限制当前线程数量的应用场景,它适用于负载比较重的服务器。
    FixedThreadPool 的 corePoolSize 和 maximumPoolSize 都被设置为创建 FixedThreadPool 时指定的参数 nThreads。
    当线程池中的线程数大于 corePoolSize 时,keepAliveTime 为多余的空闲线程等待新任务的最长时间,超过这个时间后多余的线程将被终止。如果把 keepAliveTime 设置为 0L,意味着多余的空闲线程会被立即终止。
    FixedThreadPool 使用有界队列 LinkedBlockingQueue 作为线程池的工作队列(队列的容量为Integer.MAX_VALUE)。

public class UseThreadFixedThreadPool {

    static class Worker implements Runnable {
        Random random;

        public Worker(Random random){
            this.random = random;
        }

        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName());
            SleepTools.ms(random.nextInt(100)*3);
        }
    }

    public static void main(String[] args) throws Exception {
        Random random = new Random(10);

        ExecutorService threadPool = Executors.newFixedThreadPool(3);

        for (int i = 0; i < 10; i++) {
            threadPool.execute(new Worker(random));
        }
        threadPool.shutdown();
    }
}
// 执行结果
pool-1-thread-1
pool-1-thread-2
pool-1-thread-1
pool-1-thread-2
pool-1-thread-1
pool-1-thread-1
pool-1-thread-2
pool-1-thread-1
pool-1-thread-2
pool-1-thread-1

SingleThreadExecutor

    创建使用单个线程的 SingleThread-Executor 的 API,于需要保证顺序地执行各个任务;并且在任意时间点,不会有多个线程是活动的应用场景。
    corePoolSize 和 maximumPoolSize 被设置为1。其他参数与 FixedThreadPool 相同。SingleThreadExecutor 使用有界队列 LinkedBlockingQueue 作为线程池的工作队列(队列的容量为Integer.MAX_VALUE)。

public class UseThreadFixedThreadPool {

    static class Worker2 implements Callable<String> {
        private String name;
        Random random;
        public Worker2(String name, Random random) {
            this.random = random;
            this.name = name;
        }
        @Override
        public String call() throws Exception {
            SleepTools.ms(random.nextInt(100)*3);
            return Thread.currentThread().getName()+"->"+name;
        }
    }

    public static void main(String[] args) throws Exception {
        Random random = new Random(10);

        ExecutorService threadPool = Executors.newSingleThreadExecutor();

        ArrayList<Worker2> worker2s = new ArrayList<>();
        for (int i = 0; i < 10; i++) {
            worker2s.add(new Worker2("第"+i+"个", random));
        }
        List<Future<String>> futures = threadPool.invokeAll(worker2s);
        for (Future<String> future : futures) {
            System.out.println(future.get());
        }
        threadPool.shutdown();
    }
}

// 运行结果
pool-1-thread-1->第0个
pool-1-thread-1->第1个
pool-1-thread-1->第2个
pool-1-thread-1->第3个
pool-1-thread-1->第4个
pool-1-thread-1->第5个
pool-1-thread-1->第6个
pool-1-thread-1->第7个
pool-1-thread-1->第8个
pool-1-thread-1->第9个

CachedThreadPool

    创建一个会根据需要创建新线程的 CachedThreadPool 的API。大小无界的线程池,适用于执行很多的短期异步任务的小程序,或者是负载较轻的服务器。
    corePoolSize 被设置为 0,即 corePool 为空;maximumPoolSize 被设置为 Integer.MAX_VALUE。这里把 keepAliveTime 设置为 60L,意味着 CachedThreadPool 中的空闲线程等待新任务的最长时间为 60 秒,空闲线程超过 60 秒后将会被终止。
    FixedThreadPool 和 SingleThreadExecutor 使用有界队列 LinkedBlockingQueue 作为线程池的工作队列。CachedThreadPool 使用没有容量的 SynchronousQueue 作为线程池的工作队列,但CachedThreadPool 的 maximumPool 是无界的。这意味着,如果主线程提交任务的速度高于 maximumPool 中线程处理任务的速度时,CachedThreadPool 会不断创建新线程。极端情况下,CachedThreadPool 会因为创建过多线程而耗尽 CPU 和内存资源。

public class UseThreadFixedThreadPool {

    static class Worker implements Runnable {
        Random random;

        public Worker(Random random){
            this.random = random;
        }

        @Override
        public void run() {
            System.out.println(Thread.currentThread().getName());
            //SleepTools.ms(random.nextInt(100)*3);
        }
    }

    public static void main(String[] args) throws Exception {
        Random random = new Random(10);

        ExecutorService threadPool = Executors.newCachedThreadPool();

        for (int i = 0; i < 10; i++) {
            SleepTools.ms(random.nextInt(100)*3);
            threadPool.execute(new Worker(random));
        }
        threadPool.shutdown();
    }
}

// 运行结果
pool-1-thread-1
pool-1-thread-1
pool-1-thread-1
pool-1-thread-1
pool-1-thread-1
pool-1-thread-1
pool-1-thread-1
pool-1-thread-1
pool-1-thread-1
pool-1-thread-1
// 当把 run() 方法的注释打开后,就会创建新线程执行任务了

WorkStealingPool

    利用所有运行的处理器数目来创建一个工作窃取的线程池,使用forkjoin 实现。

ScheduledThreadPoolExecutor

使用工厂类 Executors 来创建。Executors 可以创建 2 种类型的 ScheduledThreadPoolExecutor :
    ScheduledThreadPoolExecutor:包含若干个线程的 ScheduledThreadPoolExecutor。适用于需要多个后台线程执行周期任务,同时为了满足资源管理的需求而需要限制后台线程的数量的应用场景。
    SingleThreadScheduledExecutor:只包含一个线程的 ScheduledThreadPoolExecutor。适用于需要单个后台线程执行周期任务,同时需要保证顺序地执行各个任务的应用场景。

public class UseScheduledThreadPool {
    public static void main(String[] args) throws Exception {
        ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(2);

        //延迟1秒执行
        /*scheduledExecutorService.schedule(new Runnable() {
            @Override
            public void run() {
                System.out.println("延迟1秒执行");
            }
        }, 1, TimeUnit.SECONDS);*/


        //延迟1秒后每3秒执行一次
        scheduledExecutorService.scheduleAtFixedRate(new Runnable() {
            @Override
            public void run() {
                System.out.println("延迟1秒后每3秒执行一次");
            }
        }, 1, 3, TimeUnit.SECONDS);
    }
}

提交定时任务

//向定时任务线程池提交一个延时 Runnable 任务(仅执行一次)
public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit)

//向定时任务线程池提交一个延时的 Callable 任务(仅执行一次)
public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit);

//向定时任务线程池提交一个固定时间间隔执行的任务
public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit)

//向定时任务线程池提交一个固定延时间隔执行的任务
public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit);

    固定时间间隔的任务:不论每次任务花费多少时间,下次任务开始执行时间从理论上讲是确定的,当然执行任务的时间不能超过执行周期。
    固定延时间隔的任务:是指每次执行完任务以后都延时一个固定的时间。由于操作系统调度以及每次任务执行的语句可能不同,所以每次任务执行所花费的时间是不确定的,也就导致了每次任务的执行周期存在一定的波动。

定时任务超时问题

    scheduleAtFixedRate 中,若任务处理时长超出设置的定时频率时长,本次任务执行完才开始下次任务,下次任务已经处于超时状态,会马上开始执行。
    若任务处理时长小于定时频率时长,任务执行完后,定时器等待,下次任务会在定时器等待频率时长后执行。
    如下例子:
    设置定时任务每60s 执行一次,那么从理论上应该第一次任务在第 0s 开始,第二次任务在第 60s 开始,第三次任务在 120s 开始,但实际运行时第一次任务时长 80s,第二次任务时长 30s,第三次任务时长 50s,则实际运行结果为:
    第一次任务第 0s 开始,第 80s 结束;
    第二次任务第 80s 开始,第 110s 结束(上次任务已超时,本次不会再等待 60s,会马上开始);
    第三次任务第 120s 开始,第 170s 结束.
    第四次任务第 180s 开始.....

CompletionService

    CompletionService 实际上可以看做是 Executor 和 BlockingQueue 的结合体。
    CompletionService 在接收到要执行的任务时,通过类似 BlockingQueue 的 put 和 take 获得任务执行的结果。
    CompletionService 的一个实现是 ExecutorCompletionService,
    ExecutorCompletionService 把具体的计算任务交给 Executor 完成。
    在实现上,ExecutorCompletionService 在构造函数中会创建一个 BlockingQueue(使用的基于链表的 LinkedBlockingQueue),该 BlockingQueue 的作用是保存 Executor 执行的结果。
    当提交一个任务到 ExecutorCompletionService 时,首先将任务包装成 QueueingFuture,它是 FutureTask 的一个子类,然后改写 FutureTask 的 done 方法,之后把 Executor 执行的计算结果放入BlockingQueue 中。
    与 ExecutorService 最主要的区别在于 submit 的 task 不一定是按照加入时的顺序完成的。CompletionService 对 ExecutorService 进行了包装,内部维护一个保存 Future 对象的 BlockingQueue。只有当这个 Future 对象状态是结束的时候,才会加入到这个 Queue 中,take() 方法其实就是 Producer-Consumer 中的 Consumer。
    它会从 Queue 中取出 Future 对象,如果 Queue 是空的,就会阻塞在那里,直到有完成的 Future 对象加入到 Queue 中。所以,先完成的必定先被取出。这样就减少了不必要的等待时间。

我们可以得出结论:
    使用方法一,自己创建一个集合来保存 Future 存根并循环调用其返回结果的时候,主线程并不能保证首先获得的是最先完成任务的线程返回值。它只是按加入线程池的顺序返回。因为 take 方法是阻塞方法,后面的任务完成了,前面的任务却没有完成,主程序就那样等待在那儿,只到前面的完成了,它才知道原来后面的也完成了。
    使用方法二,使用 CompletionService 来维护处理线程不的返回结果时,主线程总是能够拿到最先完成的任务的返回值,而不管它们加入线程池的顺序。

public class TestCompletionService {

    static class Word implements Callable<Integer> {
        Integer i;
        public Word(Integer i) {
            this.i = i;
        }

        @Override
        public Integer call() throws Exception {
            if (i == 3) {
                Thread.sleep(5000);
            } else {
                Thread.sleep(1000);
            }
            return i;
        }

    }

    public static void main(String[] args) {
        Long start = System.currentTimeMillis();
        //开启3个线程
        ExecutorService exs = Executors.newFixedThreadPool(5);
        try {
            int taskCount = 10;
            // 结果集
            List<Integer> list = new ArrayList<Integer>();
            List<Future<Integer>> futureList = new ArrayList<>();

            // 1.定义CompletionService
            CompletionService<Integer> completionService = new ExecutorCompletionService<Integer>(exs);

            // 2.添加任务
            for (int i = 0; i < taskCount; i++) {
                Future<Integer> future = completionService.submit(new Word(i + 1));
                futureList.add(future);
            }

            // 3.获取结果
            for (int i = 0; i < taskCount; i++) {
                Integer result = completionService.take().get();
                list.add(result);
            }

            System.out.println("list=" + list);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //关闭线程池
            exs.shutdown();
        }
    }
}

// 运行结果
list=[1, 2, 4, 5, 6, 7, 8, 9, 10, 3]

_____个人笔记_____((≡^⚲͜^≡))_____欢迎指正_____

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
c语言网络编程聊天室线程池是一个在Windows平台下使用C语言进行开发的聊天室程序。它使用Socket套接字编程实现了多人聊天和私聊的功能,并支持断开重新连接。这个聊天室程序适合新手学习C语言Socket基础。同时,它还涉及到了C/s框架、多线程、进程、TCP/UDP双协议、c库文件、sqlite3等内容。根据引用,这个程序的代码量约为6000行。线程池是一个用于管理线程的技术,它可以提高程序的并发性能。使用线程池可以避免频繁创建和销毁线程带来的性能开销,提高线程的复用率,从而提升整个聊天室程序的效率和稳定性。因此,c语言网络编程聊天室线程池是一个功能强大且适合学习的项目。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* [Windows(VC doc)下C语言线程池聊天室-服务器-客户端](https://download.csdn.net/download/u010467016/8358519)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 33.333333333333336%"] - *2* [Linux c网络编程聊天室项目](https://blog.csdn.net/m0_60375038/article/details/120432608)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 33.333333333333336%"] - *3* [电子学会青少年软件编程(C语言一级)等级考试试卷(2021年6月)-含答案和解题思路.pdf](https://download.csdn.net/download/gozhuyinglong/88230811)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 33.333333333333336%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值