尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
C#线程池从入门到调优:复用、调度机制与避坑实战
从new Thread到慢慢拥抱线程池几乎是每个 C# 并发开发者的必经之路。前几篇系列文章里我们亲手创建线程、管理线程、处理退出与协作这些底层直觉非常宝贵可一旦任务量上到几十、几百甚至几千手工线程的创建开销、不可控的并发数、上下文切换的成本都会一起找上门。这时候就该认真聊聊 C# 内置的ThreadPool线程池了它把线程的创建、调度、销毁交给运行时托管让我们只关心任务是什么不用再操心线程该怎么养。这篇系列第四篇我会把线程池的适用场景、基础用法、内部调度机制、常见大坑以及我压测调优的一些真实经验一次性讲透。1. 为什么每任务开一个 Thread在生产环境里撑不住1.1 创建一条线程的隐性账单很多刚接触多线程的开发者会觉得new Thread(() DoWork())写起来极其简单粗暴但这条 Thread 的背后可不是只有几行代码的代价。操作系统要给它分配内核线程对象、用户态栈和内核态栈、线程环境块、TLS 存储C# 侧运行时还要初始化托管线程状态。这个创建过程通常需要几十微秒甚至更高而且创建之后必然会经历至少两次上下文切换从当前线程切到新线程新线程跑完再切回来。如果任务本身的执行时间就是几十微秒或几毫秒你把大部分时间都花在开店和关店上而不是干活上。我用一个不算夸张的类比每次来一个客户就开一家新店接待完立刻关店下一单再来重开。老板肯定疯掉正确做法是雇几个店员循环接待——线程池里的线程就是那几个一直在岗的店员。1.2 线程数越多吞吐量可能反而越差另一个容易被忽略的常识是线程不是无限增长的积木。你的 CPU 核心数固定可运行线程数一旦超过核心数操作系统就必须通过时间片轮转在它们之间切换。一次上下文切换大约在微秒级看似不高但当系统里有几百个线程同时竞争几十个核心时切换总开销会明显拖垮吞吐量。这不是理论推断而是我实际压测见过多次的现象线程数从核心数往上增加时前期吞吐确实上升一旦超过某个阈值再开线程吞吐不再增长反而回落。因为调度器把大量时间花在换人上。所以任务多就开更多线程这个直觉在生产环境里是反的。到底什么场景适合手工开线程什么场景适合线程池我用一张表总结一下我自己的选择标准任务特征手工new Thread线程池低频、长驻后台任务比如启动时加载缓存可以考虑但需要手动管理生命周期不太推荐长时间占用池线程会影响其他任务高频短任务消息处理、Web 请求、批量小任务不推荐创建开销占比太高最合适任务数量不可预估、可能有突发容易把并发数开爆池内有上限有动态扩容机制需要精细控制线程优先级、名称、Affinity推荐不适合池线程不提供这些控制1.3 线程池的承诺复用、平滑、有上限线程池这套机制本质上就解决三件事。第一是复用池里的工作线程在完成一个任务后不会立即销毁而是进入空闲状态继续接下一个任务第二是平滑当任务突发、线程不够用的时候线程池会渐进式地补充线程而不是一次性把所有任务所需的线程全开出来第三是上限无论你排多少任务池内的并发线程数都有硬顶不会因为任务堆积就把系统拖死。理解这三条承诺就理解了线程池绝大部分行为。下面进入实操环节看看怎么把任务真正丢进池子里。2. ThreadPool 基础使用排队、传参、等待与取回结果2.1 第一次把任务扔进线程池ThreadPool最基础的方法就是QueueUserWorkItem把一个委托排进线程池的工作队列池中某个空闲线程会取出来执行。ThreadPool.QueueUserWorkItem(_ { Console.WriteLine($池内线程: {Environment.CurrentManagedThreadId}); }); Console.WriteLine($主线程: {Environment.CurrentManagedThreadId}); Console.ReadLine();执行之后你会发现回调所在线程的 ID 和主线程 ID 不同而且回调是异步执行的排队的代码会立刻返回继续往下跑。QueueUserWorkItem返回一个bool一般情况下都是true但如果线程池因某些原因拒绝接收任务或者传入的委托为空它可能返回false或直接抛出异常。不过在正常的 .NET 运行时里拒绝入队的情况极其罕见我平时基本不检查这个返回值。如果主线程想等这个池任务执行完最常见的做法是配合ManualResetEventSlim。这是最简单的排队之后等结果的手写方式using var done new ManualResetEventSlim(false); ThreadPool.QueueUserWorkItem(_ { Thread.Sleep(500); done.Set(); }); // 在主线程等待注意这里超时很重要避免无谓永久阻塞 done.Wait(TimeSpan.FromSeconds(3)); Console.WriteLine(done.IsSet ? 任务完成 : 任务超时);注意这里我特意强调在主线程等待。如果换个场景你在线程池线程里用.Wait()去等另一个线程池任务那问题就大了这个坑我会在第 4 章专门拆解。2.2 老式 WaitCallback 与新式 lambda参数怎么传QueueUserWorkItem最古老的签名是接收一个WaitCallback委托这个委托只有一个object state参数ThreadPool.QueueUserWorkItem(DoWork, 123); static void DoWork(object state) { int value (int)state; Console.WriteLine(value); }这种写法能传参但有两个很不舒服的点。第一是装箱整数、结构体传入后会被装进object回调里再拆箱频繁传参的时候会有额外分配第二是强转容易出错一旦类型不匹配就是运行时InvalidCastException。我建议直接用 lambda 闭包捕获既避免了装箱也消除了强转int jobId 99; string fileName order_20240601.csv; ThreadPool.QueueUserWorkItem(_ { // 闭包捕获了 jobId 和 fileName ProcessFile(jobId, fileName); });不过闭包捕获有个隐蔽点lambda 捕获的变量不是排队那一刻的快照而是变量本身。如果你在QueueUserWorkItem之后又修改了jobId回调真正执行时读到的可能是修改后的值。如果希望锁定排队瞬间的数据就提前拷贝一份int jobId 99; int snapshot jobId; ThreadPool.QueueUserWorkItem(_ DoSomething(snapshot));这类捕获变量被改动引发的诡异问题在线程池这种延迟执行场景里尤其隐蔽经验不多的人排查半天都找不到原因。2.3 任务结果怎么带出来共享状态与回调QueueUserWorkItem本身没有返回值也不支持await。要让任务结果流出线程池常见的三种姿势第一种是共享字段或共享状态配合Interlocked或者锁int total 0; using var done new ManualResetEventSlim(false); ThreadPool.QueueUserWorkItem(_ { for (int i 0; i 100; i) { Interlocked.Add(ref total, i); } done.Set(); }); done.Wait(TimeSpan.FromSeconds(3)); Console.WriteLine(total);第二种是把结果塞进一个自定义回调里任务完成时主动通知调用方void PerformAsync(Actionint onComplete) { ThreadPool.QueueUserWorkItem(_ { int result HeavyCalculation(); onComplete?.Invoke(result); }); } PerformAsync(result Console.WriteLine($拿到结果: {result}));第三种最干脆这种场景根本不该用QueueUserWorkItem直接换Task.Run。TaskT能返回结果、能等待、能取消几乎把上面所有手写补丁都标准化了。很多 C# 开发者第一次用线程池时会陷入手写状态同步的泥潭写完ManualResetEventSlim 共享字段 超时控制才发现这些不都是Task早就提供的能力吗2.4 认清 QueueUserWorkItem 的能力边界我把QueueUserWorkItem的能力边界整理了一下方便大家对比需求QueueUserWorkItem说明获取任务返回值不支持只能通过共享状态或回调带出取消任务不支持排队后无法撤销只能任务内自行判断取消标志等待任务完成不支持需要ManualResetEventSlim等工具手动实现多个任务聚合等待不支持需要CountdownEvent或手动计数任务异常自动传播不支持异常会抛出到池线程上层处理不当就丢失看到这张表你大概就明白了QueueUserWorkItem是线程池最朴素的门面不是给它堆各种补充工具就能变成Task。我的建议是新项目里正式业务代码优先用Task但线程池作为底层机制必须理解否则你调Task相关性能问题时仍然一头雾水。下一章就进入线程池内部看看它到底是怎么调度任务的。3. 线程池内部调度队列、延迟注入与门限调整3.1 任务先进入全局工作项队列当你调用QueueUserWorkItem任务首先进入线程池的全局工作项队列。线程池里的空闲线程会从队列里取任务执行取完之后继续取下一个。这像一个共享的待办清单哪个员工手空了就自己抽一张单子去处理。Task.Run在大多数情况下也是排队到线程池但它的调度实现更讲究跟QueueUserWorkItem直接进全局队列不同基于线程池的默认任务调度器还有本地队列和任务窃取机制当前线程如果是池线程新任务可能优先进入所在线程的本地队列其他线程忙完自己的活以后可以从别人的队列尾部偷任务。这个细节解释了为什么在现代 .NET 代码里Task往往比裸的QueueUserWorkItem有更好的缓存局部性和扩展性。3.2 线程池不是任务一多就立刻开满线程很多人误认为线程池收到 1000 个任务就会立刻开 1000 个线程。实际上线程池的默认策略是先保持较小的线程基数当所有现有线程都忙、队列里还有任务堆积时触发饥饿模式每隔大约 500ms 尝试注入一个新线程直到线程数达到最大值。先用代码看看你机器上的线程池门限ThreadPool.GetMinThreads(out int workerMin, out int iocpMin); ThreadPool.GetMaxThreads(out int workerMax, out int iocpMax); Console.WriteLine($worker 线程min{workerMin}, max{workerMax}); Console.WriteLine($IOCP 线程min{iocpMin}, max{iocpMax});以我自己在 .NET 8 环境、8 逻辑处理器的机器上实测为例常见输出是worker 线程min8, max32767 IOCP 线程min8, max1000注意不同运行时、不同机器差别很大别把某个数值当成唯一标准。这里的min是线程池会尽量保证维持的线程下限max是硬顶。理解了这个机制你就知道一个生产事故背后的原因瞬时涌入大量并发任务后任务并不能被立刻执行而是一个一个地被慢速注入的线程处理前几百毫秒里大量任务都堆在队列里等待。3.3 突发任务那几百毫秒的启动延迟我用一个简单实验给大家直观展示延迟现象排 200 个模拟短任务每个任务记录排队时间 - 实际开始执行时间之间的间隔。默认线程池配置下你会看到前几个任务立即启动然后队列里的任务开始等线程注入部分任务的实际启动延迟可能达到几百毫秒。这不是缺陷而是线程池有意为之的保守策略线程创建本身有开销空闲线程也要占内存所以运行时倾向于够用就好。但对低延迟业务来说这种默认策略很致命——比如网关服务瞬时收到几百个入站请求每个请求都要做一小段计算如果线程池还在慢吞吞地每 500ms 加一个线程请求整体延迟就会飙升。这时你需要主动去调SetMinThreads。3.4 调 SetMinThreads 的正确姿势与翻车案例ThreadPool.SetMinThreads有两个参数工作线程数和 I/O 完成端口线程数。一个常见经验值是让最小线程数贴近稳态并发量我压测时的参考做法是先按逻辑处理器数量的 2 倍起步int baseCount Environment.ProcessorCount; ThreadPool.SetMinThreads( Math.Max(16, baseCount * 2), Math.Max(16, baseCount * 2));但必须强调不要盲目把 min 设成 64、128然后让池子常年养着一堆闲人。每个线程默认栈空间就有 1 MB 左右的虚拟内存线程控制块、TLS、内核对象也都不是免费的。我见过有人把 min 设成 256 以追求极致启动速度结果空闲时段进程占用暴涨反而引发运维告警。正确的套路是先压测再调参。具体做法我放在第 5 章最后统一讲先记住一条红线SetMinThreads里设置的值不能大于max否则运行时会直接抛出ArgumentOutOfRangeException。3.5 线程也是会退休的不要用线程池线程数判断负载线程池在任务批次完成后不会立刻销毁所有线程。空闲线程会保留一段时间但如果持续没有新任务超过min部分的线程会被逐渐回收。这个退休过程不是即时的时间大概率会延续到几十秒甚至更久。所以你在生产环境里监控线程数时会发现一个现象任务高峰已经过去十分钟进程里的线程数还是维持在高位。很多运维同学看到线程数不降就紧张其实线程池就是这么设计的——保留部分空闲线程等待下一波突发避免频繁创建销毁。想判断当前并发负载不能只看线程数而要看可用线程数、队列长度、CPU 占用等综合指标。这也引出了线程池最常见的那几个坑下一章全部摊开讲。4. 线程池里最容易踩的坑死锁、上下文跳变与状态残留4.1 在池线程里同步等待另一个池任务教科书级死锁这是线程池相关的第一号大坑在线程池回调内部使用.Wait()或.Result去等待另一个排进线程池的任务。为了稳定复现这个死锁我先用SetMaxThreads把池线程上限调成 2再排两个任务每个任务内部都等待一个子任务完成ThreadPool.SetMaxThreads(2, 2); using var done new ManualResetEventSlim(false); long completed 0; for (int i 0; i 2; i) { ThreadPool.QueueUserWorkItem(_ { // 等待一个同样依赖线程池的子任务 Task.Run(() Interlocked.Increment(ref completed)).Wait(); done.Set(); }); } // 加超时避免进程永久卡死 done.Wait(TimeSpan.FromSeconds(5)); Console.WriteLine($completed {Volatile.Read(ref completed)});运行结果基本是 5 秒超时completed仍然为 0。原因很清楚两个外层任务占满了仅有的两个池线程它们各自又在等一个需要空闲池线程才能执行的子任务。线程池已经到达 max不会再注入新线程于是所有线程都在等待永远不会被调度到的任务——死锁。真实生产环境不会把 max 设成 2但原理完全一样当池内线程全部被等待其他池任务占住子任务永远得不到执行。.NET 6 之后同步阻塞地等待异步工作更容易引发这类问题。应对思路是在线程池回调里避免任何同步等待改用异步链式调用或直接让数据结构脱离池线程。如果你在设计阶段就发现自己在写这种等待式代码请停下来用Task重新组织逻辑。4.2 回调线程无根没有同步上下文别乱碰 UI桌面应用或者 Web 后端里线程池回调的线程和发起排队的线程没有任何归属关系。最直观的例子是 WPF/WinForms 程序里在 UI 线程上排队任务回调执行时如果直接操作 UI 控件大概率会抛出调用线程无法访问该对象的异常。原因在于ThreadPool在执行任务时会传播ExecutionContext例如AsyncLocal数据可以在排队时被捕获并流入池线程但它不会自动传播SynchronizationContext。UI 控件的线程亲和性依赖的就是同步上下文池线程上根本没有这个上下文。一个实用经验是在桌面端不要让线程池回调直接接触 UI先在 UI 线程上做界面操作把耗时任务丢进线程池回调里再通过调度器把结果切回 UI 上下文。这条规则的底层逻辑也适用于 Web 场景你在请求上下文里排了一个线程池任务想在任务里使用当前请求相关上下文数据会发现连接可能已经没了原因同样是上下文不会自动跟随。4.3 ThreadLocal / 静态变量在复用线程上的残留问题线程池会复用线程这是它的核心优势但在状态管理上就成了双刃剑。如果你用ThreadLocalT或者ThreadStatic存当前任务相关的数据而任务结束前没有清理这些数据就会残留在池线程上下一个被同一线程处理的任务很可能读到上一个任务的垃圾值。我举一个典型例子var mark new ThreadLocalint(() 0); ThreadPool.QueueUserWorkItem(_ { mark.Value 100; // 任务 A 写入状态 }); ThreadPool.QueueUserWorkItem(_ { // 任务 B 并没有设置 mark如果它恰好被同一个池线程处理 // 可能读到 100而不是初始值 0。 Console.WriteLine(mark.Value); });因为是线程复用两次任务是否落在同一线程完全取决于调度时机所以这种 bug 的复现概率极不稳定。你可能连续跑十次都正常第一百次突然出现脏值日志里又没有任何局部逻辑错误。我的建议是在线程池场景里ThreadLocal和ThreadStatic只用来存与线程本身相关的东西比如线程标识、池化连接对象、日志字段等绝不要用来传递任务级别的业务状态。任务相关数据应该通过参数、AsyncLocal或任务对象本身传递。4.4 全局锁竞争线程池会自动加派人手但都会堵在门口最后一个常见问题不是死锁而是性能自杀。当大量线程池任务同时竞争同一把全局锁时一开始可能有 8 个池线程在处理任务8 个线程都在抢锁排队越来越多线程池检测到队列堆积后开始继续注入线程。新注入的线程也拿不到锁只能在锁上等待。结果就是线程数增加、上下文切换增加、锁的等待队列变长系统吞吐量不但没上去反而断崖式下降。我处理过类似性能事故某服务单线程处理数据库写操作没问题后来改造成多线程池并发之后响应反而更慢火焰图一抓全是锁等待。线程池本身没有错错的是让所有任务都扎堆共享同一个临界资源。这类问题的解法不是关小线程池而是尽量减少共享临界区按 key 分片加锁、使用读写锁、改用无锁数据结构或者把必须串行的部分用单消费者队列隔离开。还有一个临时的缓解手段是把max调小限制并发线程数量降低切换和锁竞争但这只是止血根治还得从并发设计下手。5. 进阶认知I/O 线程、Task.Run以及我自己压测调优的门限方法5.1 别再只盯 worker 线程I/O 完成端口线程同样会被饿死很多开发者对线程池的理解停留在工作线程层面忽略了ThreadPool里还有另一类线程I/O 完成端口IOCP线程。像异步文件读写、网络 Socket 接收、管道通信这类操作完成通知会通过系统完成端口投递.NET 用 IOCP 线程来执行对应的回调。这就带来一个隐蔽的生产坑网络服务并发量极高时如果 IOCP 线程跟不上 I/O 完成回调的处理速度你会看到await一直不返回、请求超时但 CPU 占用并不高。很多人的第一反应是没活干实际上是 IOCP 线程饿死了异步回调根本排不上队。监控方式很简单用GetAvailableThreads分别看两类线程ThreadPool.GetAvailableThreads(out int worker, out int iocp); ThreadPool.GetMaxThreads(out int maxWorker, out int maxIocp); Console.WriteLine($worker 可用 {worker}/{maxWorker}); Console.WriteLine($IOCP 可用 {iocp}/{maxIocp});如果你的服务是重度异步 I/O 型调整SetMinThreads时一定记得把第二个参数也带上不能只调worker。5.2 Task.Run 与 ThreadPool 的关系同一批工人不同的派单系统Task.Run在默认情况下是把任务排给线程池执行的可以认为它是线程池的现代化门面。两者共享同一池线程但Task在体验上做了大量增强维度QueueUserWorkItemTask.Run返回结果不支持TaskT直接返回等待手动事件await/Wait取消不支持CancellationToken异常传播容易丢失由 Task 保存等观察时重新抛出调度优化全局队列本地队列 工作窃取时间复杂度简单直接更现代适合生产代码这里的实践经验是新代码优先写await Task.Run(...)只有在极简单的后台任务里才用QueueUserWorkItem。但我不建议你完全绕过线程池去理解Task因为Task的性能调优最终仍会落回线程池参数上。还有另一种情况不要用Task.Run如果你的任务是长时间运行的阻塞任务比如持续监听的消费者循环直接排队到线程池会长时间占住一个池线程拉高线程池压力。标准做法是使用Task.Factory.StartNew(..., TaskCreationOptions.LongRunning)或者干脆自己维护专用线程。判断标准很简单任务是否会阻塞超过几秒如果会就不应该占用通用池线程。5.3 什么场景不适合线程池线程池不是万金油至少有三种场景我明确不建议硬塞进池子。第一长期驻留的后台循环任务比如消息队列消费者、日志刷盘循环交给LongRunning任务或独立线程更合理占住池线程会让在线并发任务无端受限。第二对实时性要求极高的任务比如音频采集、硬件控制中的高频采样这类任务需要稳定的线程优先级和可预测的调度行为而线程池为了整体吞吐会牺牲单个任务的确定性。第三需要线程单元模式的组件调用例如某些 COM 组件要求 STA 线程而线程池线程默认不满足这种要求。强行使用会在调用时出现奇怪的副作用不如直接开一个专门线程。5.4 我压测线程池门限时的完整套路最后分享一条我从实际性能事故里总结出来的调优流程希望能帮你少走弯路。第一步准备一组固定数量的突发任务任务内做固定耗时的模拟计算并记录三个时间点排队时间、实际开始执行时间、完成时间。第二步在默认min配置下跑几轮重点看排队到开始执行的延迟分布尤其关注 P99 和最大值。第三步以 2 倍核心数为起点逐步提高SetMinThreads每轮重新压测观察启动延迟曲线的变化。第四步当继续提高min不再带来明显延迟改善、或者空闲线程比例变高时就停下来以当前值作为初始配置。从我的经验看很多服务的稳健状态在核心数的 2 到 4 倍之间但千万不要直接照抄因为你的任务耗时、I/O 占比、并发模型都不一样。内存占用、上下文切换、线程注入时间三者要一起权衡。还有一个很容易被忽略的细节部署环境最终决定一切。某些容器化部署里进程看到的逻辑处理器数量可能与宿主机不一致线程池的默认门限也会随之变化。你在一台 32 核开发机上调的参数发布到配额更小的环境里可能完全不合适所以压测一定要在尽可能贴近生产环境配置的机器上做发布后还要持续观察GetAvailableThreads的采样数据。我自己做压测时有个小习惯写一个简单的监控循环每一秒把可用 worker / 可用 IOCP打出来在高流量时段盯着看。如果worker 可用经常降到很低的水平说明池线程长期吃紧适合提高min如果长时间接近max可能不是min的问题而是业务并发设计或锁竞争的问题。通过这个观察思路你能比大多数只盯着 CPU 的人更早发现线程池层面的隐患。这也是我这一系列关于多线程实践最想传达的思维方式先理解背后的调度逻辑再动手调参数。
RELATED

相关推荐

KVM虚拟化平台搭建实战:从硬件原理到性能调优

KVM虚拟化平台搭建实战:从硬件原理到性能调优

前阵子帮一个团队搭测试环境,一台四核物理机既要跑业务接口、又要跑数据库,晚上还有定时任务。一开始图省事,所有服务直接装在宿主机上,结果某个报表任务把 CPU 吃满,接口超时一片,数据库也跟着遭殃。后来又…

📅 2026/10/12 9:33:07
C#数组底层原理与高性能实践指南

C#数组底层原理与高性能实践指南

1. 为什么“数组”是C#开发者绕不开的第一道坎刚接触C#时,我见过太多人把Array当成一个“会用就行”的基础类型——声明、赋值、遍历,三步走完就去学类和继承了。结果半年后写一个日志批量处理模块,面对上万条记录的内存拷贝性能骤降&#xf…

📅 2026/10/12 9:33:07
CloudFoundry 入门实战:从 cf push 到应用运维全解析

CloudFoundry 入门实战:从 cf push 到应用运维全解析

1. 从零认识 CloudFoundry:它到底解决什么问题第一次接触 CloudFoundry 是在一个内部技术分享会上,当时团队正被一堆部署脚本和手工配置搞得焦头烂额。有人提了一句“要不试试 CF”,那是我第一次听到这个名字。后来花了两周时间把一套模拟项目…

📅 2026/10/12 9:33:07
MORE NEWS

更多资讯

📰

虚拟电厂多时间尺度调度建模与Matlab实现解析

1. 项目概述:为什么虚拟电厂需要“两套调度系统” 这两年“虚拟电厂”在电力圈的热度一直没降过。简单说,虚拟电厂不是物理上的一个电厂,它把光伏、风电、储能、充电桩、可控负荷这些分散的资源聚合起来,通过统一调度,…

📰

Python列表和元组:序列类型操作、可变性陷阱与编码实践

1. 为什么要单独讲列表和元组:从"存一个数"到"存一串数"的跨越1.1 数据变多之后,普通变量就不够用了先看一个非常朴素的场景。你现在要写一个程序,记录全班三十个学生的考试成绩。用上一课学的知识,你会怎么做…

📰

DPDK高性能收包实战笔记:原理、配置与排障全解析

简介:这是一份面向DPDK学习者与网络性能优化人员的《深入浅出DPDK》全书读书笔记。笔记从传统网卡中断驱动在高速网络下的性能瓶颈切入,逐步梳理NAPI轮询、Netmap共享包池、用户态驱动等演进方案,并深入讲解DPDK的多队列流分类、大页内存、无…

📰

装饰模式实战:用组合替代继承实现职责分离

1. 项目概述:为什么装饰模式是“单一职责”的第一块试金石?你有没有遇到过这样的场景:一个类刚写完,功能跑得挺顺,结果产品经理第二天就甩来三条需求——要加日志、要支持缓存、还要做权限校验。你盯着那个已经三百行的…

📰

银河麒麟下SVN源码编译安装及配置实战指南

简介:面向在银河麒麟操作系统中自建SVN版本控制服务的中高级运维人员与项目管理员,这份资料以Word文档形式提供完整的源码安装流程。正文以subversion-1.8.14、apr-1.6.5、apr-util-1.6.1、sqlite-amalgamation-3071501的版本选择为起点,先交…

📰

初创企业建站选型实测:BBWEYY、Codex+亚马逊AWS、比文云与Dreamweaver的低成本验证与上线速度对比(含TaoToken统一Key接入)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬