本系列文章以我的个人博客的搭建为线索(GitHub 仓库:Evian-Zhang/evian-blog),记录我在现代化程序设计中的一些笔记。在这篇文章中,我将以我的理解从头开始梳理一遍异步编程。

从网络IO开始

作为一个服务器程序,最重要的就是维护网络的IO。我们知道,一个TCP连接对应一个TCP套接字,服务器程序需要做的,就是妥善处理这些套接字中的数据。粗略地说,一个服务器程序做的事如下:

  1. 告诉内核自己监听了哪些套接字端点(socket endpoint)
  2. 内核维护TCP连接,并将接收到的数据传递给服务器程序
  3. 服务器程序处理数据

这就好比一家快餐店,而内核中的一个套接字端点就是厨房,厨房中有许多条流水线,比如说薯条流水线、汉堡流水线和炸鸡流水线,这些流水线就是与该套接字端点连接的套接字。服务器程序的作用就是服务员,他需要在每条流水线制作完成一个食材后就将对应的食材取出,并进行进一步的处理。

怎样实现这样的功能呢?这实际上是经历了长久的演变(以Linux为例)

accpetrecvfrom

最原始的方法就是acceptrecvfrom,这默认是一种阻塞式的IO。用快餐店的例子类比的话,服务员就在厨房门口干等,啥事也不干,看着哪个流水线好了就处理哪边的食材,处理完了继续回来干等着。

也就是说,我们以下的程序

int s = socket(...); // create socket endpoint
// bind and listen
int c = accept(s, ...); // create socket
recvfrom(c, ...); // receive data

做了什么事呢?

  1. 告诉我们的服务员去哪个厨房工作,也就是创建、绑定和监听套接字端口。
  2. 如果此时厨房里没有流水线在工作,那么服务员啥也不干。也就是当前套接字端口没有连接时,该进程被移至等待队列中。在程序中就是调用accept函数被阻塞。
  3. 当厨房中出现了一个流水线,服务员就盯着这个流水线,啥也不干。也就是当accept出现返回值时,调用recvfrom函数,当没有数据返回时进程被阻塞。
  4. 等到流水线处理完毕食材,服务员就开始处理对应的食材了。也就是当recvfrom函数返回之后,进程就可以继续运行了。

这样的设计看上去就让人很难受。首先,accept函数会阻塞进程,但这是合理的,因为我们假定我们的服务器程序只用作处理网络连接,那么没有网络连接的时候自然就不用工作。但是,在accept之后,进程只能处理这一个套接字,并且当没有数据的时候,进程就被彻底阻塞了。

想象一下这样的情景:汉堡流水线最先开始工作,所以服务员就到汉堡流水线前面干等着,盯着看。之后,周围的薯条流水线、炸鸡流水线也开始工作了。但是,做汉堡的师傅比较慢,周围薯条流水线都做好十份了,炸鸡流水线也做好五份了,汉堡流水线还没做好一份汉堡,但这时服务员只能干等着汉堡流水线的师傅,造成极大的资源浪费。

有了多线程技术以后,这种现象稍微有所缓解。我们可以想象成,快餐店多请了几个服务员,比如说一共有8个服务员了。那么可以每个服务员盯着一个流水线,这样就不会产生刚刚的现象了。真的吗?并不。我们知道,服务员的数量是有限的,那么如果此时流水线的数量大于服务员的数量,依然会有流水线得不到照顾。与此同时,流水线的另一端并不是我们掌控的,而是用户发起的。那么,会不会有坏用户,只与我们的服务器建立连接,但不发送数据。那么我们如果有一个服务员被分配到这样一个流水线上,就会造成在很长一段时间里,这个服务员都啥也不干,而别的流水线却极度需要服务员来处理食材。如果坏用户多了,把我们的服务员都占用了,那么我们也就没有服务员能处理正常的流水线了。这就是DoS攻击的一种手段。

非阻塞IO

缓解这种困境的一种手段就是是由非阻塞的IO。服务员不再在流水线前干等,而是走到流水线前,看一眼流水线好了没有。如果流水线好了,生产出了我们要的食物,那么服务员就正常工作;如果流水线还没好,那服务员就不再干等了,可以去干别的事了。对于单个套接字来说,这个工作就是由非阻塞IO完成的。我们用socket创建套接字端点的时候,可以指定为非阻塞套接字,那么我们接下来对非阻塞的套接字使用recvfrom的时候,如果数据还没有准备好,函数可以直接返回,而不是被阻塞。

IO多路复用:selectepoll

除此之外,recvfrom每次只能处理一个套接字,所以人们引入了selectselect相当于我们雇用了一个厨房的总管,服务员每次向厨房总管调用select语句之后,厨房总管看一遍所有的流水线,然后告诉服务员,有没有流水线是已经完工的。如果有流水线已经为做好食材,那么服务员就去挨个看流水线,找到是哪个流水线完成的,然后处理那个流水线的食材就好了。

之后select由于一些设计上的缺陷,又产生了poll函数,但两者实际原理都是差不多的。

但是我们想象一下,如果是一个大型的服务器程序,那么可能会有成千上万个套接字连接,那么厨房总管告诉服务员有已经好了的流水线时,服务员又得从头遍历一遍所有流水线,这样的事件损耗是巨大的。

epoll就是为了改善这种情况而发明的。改善的方法也很简单,既然厨房总管也要看一遍哪个流水线好了,那么厨房总管就拿个纸,记下来好了的流水线,然后交给服务员让他去找就完事了,完全不需要服务员再遍历,这就是epoll的作用。

信号驱动与异步IO

我们发现,随着人们技术的提高,服务器程序的水平也越来越高了。我们快餐店的服务员,从原来只会干等,到现在会看一眼进行判断,或者和厨房总管进行交接了。

但是,厨房里面的水平却没有多少提高。这导致我们的服务员的效率依然很低。比如说,我们终于用上了非阻塞IO,然后服务员负责某一个流水线。他首先看到流水线还没好,就去干别的事了。别的事干完之后,他又来看一遍,发现还没好,然后又去干别的事,然后又来流水线这看一眼。如果他此时并没有别的事,那他还是一直在流水线跟前看一眼,看一眼,看一眼,和之前的阻塞式IO没有区别。反映到程序里,我们的代码依然是

// create nonblocking socket c via fcntl
while (true) {if (recvfrom(c, ...) != -1) {// receive data} else if (errno == EAGAIN) {// do something else}
}

依然是这样的循环结构。

虽然我们用上了非阻塞IO、IO多路复用,但这样始终让人感觉别扭。我们回想一下,现实中服务员和流水线,似乎也没有这么别扭的事发生啊。

这一切的原因,都是我们的编程思路没有跳脱开来,仍然局限在同步编程的概念里。我们想象一下现实生活中究竟是怎样的:服务员在忙别的事,这条流水线的食材做好了,师傅就按个铃。服务员听到铃声之后,要么立刻停下手头的事去流水线,要么默默记下来,等做完手头的事以后就去流水线。如果没有铃声,那么服务员就始终不用去流水线门口等着了。这就是异步编程的思想。

Linux中,在网络IO中引入了信号驱动型IO和aio来完成厨师的按铃工作。其主要思想就是,将一个函数传递给内核,告诉内核如果好了就调用这个函数。信号驱动型IO是在数据报的传输和内核处理层面的异步,也就是说,信号驱动型IO需要我们传递一个函数给内核,告诉内核如果来数据报了,并且内核已经把数据报处理好了,就调用我们之前传入的信号处理函数。而我们依然要在信号处理函数中使用recvfrom等函数,将内核数据拷贝到用户态来。而aio则是更进一步,告诉内核,如果来数据报了,内核把数据报处理好了,并且也已经拷贝到用户态了,再调用我们传入的函数。

异步编程

从最原始的网络IO开始,我们一步步终于接近了异步编程。异步究竟是什么呢?异步实际上就是一种编程的思维,它在代码上就体现为,我们现在写的东西不会立刻被调用,甚至这个东西也不是被它的执行者直接调用。假如我们是快餐店的老板,那么我们告诉服务员,如果厨师按铃了,那你就去端菜。「服务员去端菜」这件事并不是在他知道这件事之后就立刻去做,而是要等到厨师按铃之后才做;同时,这件事虽然是服务员自己执行的,但是并不是服务员自己调用的,而是厨师通过按铃调用的。这就是异步编程的思想。

回调函数

异步编程最原始的实现就是回调函数。比如说我们用Swift来实现我们的异步快餐店:

func makeHamburger(completionHandler: (_ hamburger: Hamburger) -> Void) {let hamburger = // make hamburgercompletionHandler(hamburger: hamburger)
}func serve(_ hamburger: Hamburger) {holdTheHamburgerToCustomers(hamburger)
}makeHamburger(completionHandler: serve)

makeHamburger是厨师该干的活,而我们需要让厨师接受一个回调函数作为参数。接着,我们实现服务员听到铃声后该干的事,也就是serve函数。我们需要做的,就是将服务员该干的事传递给makeHamburger。在这之后,当汉堡制作完成后,我们需要在函数内部调用回调函数即可。

看上去很不错,这就是我们的异步编程了!我们还需要别的吗?这看上去多简洁明了!

然而,我们还是太天真了。我们来看callbackhell.com官网上给出的一个JavaScript中的例子:

fs.readdir(source, function (err, files) {if (err) {console.log('Error finding files: ' + err)} else {files.forEach(function (filename, fileIndex) {console.log(filename)gm(source + filename).size(function (err, values) {if (err) {console.log('Error identifying file size: ' + err)} else {console.log(filename + ' : ' + values)aspect = (values.width / values.height)widths.forEach(function (width, widthIndex) {height = Math.round(width / aspect)console.log('resizing ' + filename + 'to ' + height + 'x' + height)this.resize(width, height).write(dest + 'w' + width + '_' + filename, function(err) {if (err) console.log('Error writing file: ' + err)})}.bind(this))}})})}
})

这就是完全按照回调函数的思路写出来的异步编程代码。我们来看最后几行,全都是})}。一层一层的回调函数看上去让人头皮发麻。就好像:

  • 我们告诉养鸡的,去把鸡养大,等鸡养大之后:

    • 让他告诉杀鸡的,让他准备好刀,刀准备好了之后:

      • 去杀鸡,然后告诉炸鸡的去准备好油,油准备好之后:

        • 去炸鸡,然后告诉服务员让他做准备:

          • 去把炸鸡端给顾客。

这一层一层的传递关系已经够乱的了,接下来,为了让我们的程序更稳健,我们还需要做错误处理。我们得:

  • 让服务员出错的时候,告诉炸鸡的不能端菜了

    • 炸鸡的自己出错,或者收到服务员出错的消息,就告诉杀鸡的不能炸鸡了

      • 杀鸡的自己出错,或者收到炸鸡的出错的消息,就告诉养鸡的不能杀鸡了

        • 养鸡的自己出错,或者收到杀鸡的出错的消息,就告诉我们

合起来,我们得这样写:

  • 我们告诉养鸡的,去把鸡养大,等鸡养大之后:

    • 让他告诉杀鸡的,让他准备好刀,刀准备好了之后:

      • 去杀鸡,然后告诉炸鸡的去准备好油,油准备好之后:

        • 去炸鸡,然后告诉服务员让他做准备:

          • 去把炸鸡端给顾客。
          • 服务员出错的时候,告诉炸鸡的不能端菜了
        • 炸鸡的自己出错,或者收到服务员出错的消息,就告诉杀鸡的不能炸鸡了
      • 杀鸡的自己出错,或者收到炸鸡的出错的消息,就告诉养鸡的不能杀鸡了
    • 养鸡的自己出错,或者收到杀鸡的出错的消息,就告诉我们
  • 我们来处理所有人出错的问题

让人头皮发麻,并且没办法维护。

响应式编程与观察者模式

除了回调函数之外,我们还有别的异步编程的方法。试想,使用回调函数的方法,我们为了让服务员正确服务,我们却得告诉厨师去调用服务员的函数。厨师本来就应该做好自己的事,而不是去关心别的人的事。这在程序开发中叫做弱耦合。为了实现弱耦合,我们可以引入观察者模式,它发扬光大就是著名的响应式编程。

观察者模式说的是,我们可以将我们需要关心的东西设置为可观察的对象,然后将某些东西设置为它的观察者。当这个对象发生改变的时候,它的观察者就会做出相应的举动。在我们的快餐店中,我们可以让服务员成为汉堡的观察者。当汉堡做好的时候,服务员就去端汉堡。

C#中的Rx是响应式编程的元老(但我没用过),以及Android框架中的LiveData就是观察者模式的一个很好的实现。

class WaiterViewModel : ViewModel() {private var hamburger: MutableLiveData<Hamburger?> = MutableLiveData(null)fun getHamburger(): LiveData<Hamburger?> {return this.hamburger}fun makeHamburger() {val hamburger = // make hamburgerthis.hamburger.value = hamburger // or this.hamburger.postValue(hamburger) if in another thread}
}class WaiterFragment : Fragment() {override fun onViewCreated(view: View, savedInstanceState: Bundle?) {super.onViewCreated(view, savedInstanceState)// get cook the viewModel by delegationval cook: WaiterViewModel by viewModels()// create Observercook.getHamburger().observe(viewLifeCycleOwner, Observer { hamburger inhamburger?.let {holdTheHamburgerToCustomer(it)}})}
}

在这里,我们将服务员看作一个界面,厨师看作它的view model,重点就在observe函数,指定了如果汉堡发生了变化,也就是产出了汉堡,那么服务员要去端汉堡。

值得注意的是,这和我们最开始的同步模式,也就是服务员要一直盯着汉堡不同,这里服务员依然可以去做别的事。

与观察者模式类似的,我们还有订阅-发布模式,其本质与观察者模式相同,只不过,我们让厨师做好汉堡之后,发一个公告,说汉堡做好了。然后服务员订阅这个布告栏,如果发布了公告,那么服务员就去端汉堡。

实践订阅-发布模式的,就是Apple在去年推出的Combine框架。利用Combine框架,我们可以这么写:

func makeHamburger() -> Publisher<Hamburger> {// make some hamburger
}func serve(_ hamburger: Hamburger)makeHamburger().sink { hamburger inholdTheHamburgerToCustomer(hamburger)}.store(in: disposables) // store in disposables in case we lose reference

不管怎么样,我们都是把回调函数从厨师那边解放了,而把回调函数独立于两者之外,由我们来指定。

这种模式还有一种巨大的好处是我们可以主动取消回调。比方说客人点了一份炸鸡,然后服务员让厨师做炸鸡。服务员就观察着这个炸鸡,一旦它做好,就端给客人。但是客人临时有事,又要走了。服务员就取消观察这个炸鸡就ok了。但是在回调函数的方案中,函数是直接传给厨师的,取消起来很麻烦。

协程,Promiseasync/await

观察者模式可以做的远远不止服务员端汉堡这么多。比如说服务员可以一直观察着厨师做汉堡的过程。当厨师烤好肉了之后,服务员大喊一声“厨师肉烤好啦!”,之后厨师又把肉、菜、酱加在一起,服务员大喊一声“厨师加了菜和酱啦!”,厨师最后做完汉堡,服务员才开始把汉堡端给顾客。也就是说,观察可以是一段时间持续的观察,期间任何的变动都可以反馈给观察者让观察者做出相应的举动。

但是,有时候我们的需求不是这样的。我们仅仅需要的是一个结果,某人做了某事,我们就怎么样。比方说,我们的薯条厨师,开始做薯条的时候,要先打个电话给土豆场,让他们送土豆过来。土豆送过来之后,厨师又让学徒切土豆。切好之后,厨师又让助手把油烧开,等油烧开之后,厨师才开始炸薯条。这个过程,依然是一个需要异步调用的过程,而且如果用回调函数的方式写,又会产生之前所说的callback hell。

Promise

那我们就模仿之前观察者模式的思路,看看能不能解决。这里,我们发现,这里面的环节只会变化一次。土豆会从无变成有,从没切好变成切好,从没炸变成炸过。我们模仿之前的LiveData<T>或者Publisher<T>,这里我们记为Promise<T>。它有两种状态:还没好的状态,和好了的状态。根据之前观察者模式的经验,我们似乎可以这样写:

function bringTomato(): Promise<Tomato>;
function sliceTomato(tomato: Tomato): Promise<SlicedTomato>;
function fryTomato(slicedTomato: SlicedTomato): Promise<Chips>;

当我们调用bringTomato之后,会立刻返回一个Promise<Tomato>。当土豆到了以后,这个变量会自动变成OK的状态。然后,就像之前的观察者或者订阅者一样,我们对这个变量进行观察或订阅,并传入一个回调函数:

bringTomato().then(sliceTomato);

Promise<Tomato>从没好的状态变成好的状态的时候,会调用then中的函数,并把好了的值传进去。看上去很OK嘛!

那么,then应该是怎样的工作呢?这很像我们之前在函数式编程中讲的functor和monad,这里,我们应该把then看作monad中的bind。这是因为,我们的sliceTomato依然会返回一个Promise类型的值,如果是functor的fmap的话,在执行完毕后,会产生Promise<Promise<SlicedTomato>>这样奇奇怪怪的类型,所以还是用monad比较顺心一点。也就是说,会自动“折叠”我们的Promise,让它只剩下最后一个。这样的话,我们就可以把它串起来了:

bringTomato().then(sliceTomato).then(fryTomato).then(doSomethingWithChips);

看上去还是挺OK的。

协程与async/await

那我们换一种思路,用最原始的同步方法行不行?行!薯条厨师打电话给土豆场之后,啥也不干,等着土豆送到之后,让学徒切土豆。切好之前,又啥也不干……这样确实可以有效地解决回调地狱的问题,但是这又回到了最原始的同步思路上,在我们实际程序中,一个厨师可能就是一个线程。让厨师啥也不干就干等着,这线程的利用率也就太低了。如果我们单纯写

let tomato = bringTomatoSync();
let slicedTomato = sliceTomatoSync(tomato);
let chips = fryTomatoSync(slicedTomato);

这里sync代表是同步的函数,那么当事情还没完成的时候,线程会被阻塞,不符合我们异步的想法。我们能不能用类似于上面的语句,来写出之前用Promise的功能呢?像这样:

let tomato = await bringTomato();
let slicedTomato = await sliceTomato(tomato);
let chips = await fryTomato(slicedTomato);

它和我们的同步写法究竟有什么区别呢?要解决这个问题,就要首先思考,我们为什么不用同步写法。同步的写法就意味着当函数调用还没结束的时候,我们需要一直处于等待状态。但是,如果可以在bringTomato的时候,我们的炸薯条的师傅去做别的事,那么效率就可以得到提升。也就是说,我们之所以不用同步的写法,是为了让程序在等待的过程中能做别的事。

那么,我们如果要实现这样的功能,需要怎样的策略呢?作为一个厨师,有条理地做这种事,应该是:

  1. 打电话给土豆场,让他们运土豆过来
  2. 做别的事
  3. 别的事做完以后,再来处理运来的土豆

也就是说,我们需要在特定的时候主动停下手头的事,然后去做别的事。也就是说,我们需要有主动停止的能力,或者说,主动让出控制权的能力,这就是协程。在JavaScript中,语言提供了一个叫generator的机制来模拟这种事:

function *foo() {yield bar1();bar2();yield bar3();bar4();return;
}

当我们调用foo的时候,会首先执行bar1,然后函数就会返回。当我们再次调用这个函数的时候,它并不会从头开始执行,而是从yield的后一行,也就是bar2开始执行,一直运行到下一个yield,也就是执行完bar3,然后再次停止。也就是说,这个函数的执行状态是未执行——执行——挂起——继续执行——挂起——继续执行——返回。与我们正常函数的未执行——执行——返回有着一些区别。我们还可以从状态机的角度来看这个问题:

function foo() {let state = 0;stateMachine = () => {switch (state) {case 0:bar1();state = 1;break;case 1:bar2();bar3();state = 2;break;case 2:bar4();state = 3;break;default:return;}};return stateMachine;
}

上面的这个函数返回的是一个闭包,我们可以这样调用:

let stateMachine = foo(); // state = 0
stateMachine(); // state = 1
stateMachine(); // state = 2
stateMachine(); // state = 3

这里通过状态机,使我们的程序看上去真的有了暂时让出执行之后,继续恢复挂起的能力!

当然,协程的实现方法还有很多种,有有栈协程、无栈协程等。除了Go语言之外,大多数编程语言都选择的是无栈协程,往往把我们上述的generator编译成一种状态机来实现。

通过协程,我们的async/await机制似乎就容易理解了。await实际上就是一种yield的机制,把这个函数的执行权让出去。但是,这又和async有什么关系呢?我们来看上面用状态机实现的协程,会发现很难实现在层叠调用的时候的yield,比如说:

function somethingUnreal() {bar5();yield bar6();bar7();
}function *foo() {yield bar1();bar2();yield bar3();bar4();somethingUnreal();return;
}

我们希望在somethingUnreal里的bar6yield能够停止foo的执行,然后恢复之后,继续执行bar7(),然后从somthingUnreal返回到foo里执行它后面的return语句。这用状态机似乎很难实现。所以,我们需要用async来做这个标记:

async function somethingReal() {bar5();await bar6();bar7();
}async function foo() {await bar1();bar2();await bar3();bar4();await somethingReal();return;
}

通过这样具有感染性的async,就能正确地用状态机实现协程了。

现代化程序开发笔记(11)——异步编程杂谈相关推荐

  1. python质数列_现代化程序开发笔记(3)——多文件与模块

    本系列文章以我的个人博客的搭建为线索(GitHub 仓库:Evian-Zhang/evian-blog),记录我在现代化程序设计中的一些笔记.在这篇文章中,我将对现代编程语言的多文件和模块部分进行一些 ...

  2. 现代化程序开发笔记(4)——包管理工具

    本系列文章以我的个人博客的搭建为线索(GitHub 仓库:Evian-Zhang/evian-blog),记录我在现代化程序设计中的一些笔记.在这篇文章中,我会就项目构建工具和包管理工具做一些讨论,先 ...

  3. 微信小程序开发笔记 进阶篇③——onfire.js事件订阅和发布在微信小程序中的使用

    文章目录 一.前言 二.onfire.js介绍 三.API介绍 四.实例应用 五.onfire源码 六.实例源码 一.前言 微信小程序开发笔记--导读 二.onfire.js介绍 一个简单实用的事件订 ...

  4. 微信小程序开发笔记,你收藏了吗?

    ** 微信小程序开发笔记,你收藏了吗? ** 最近在开发微信小程序,把自己在项目中经常遇到的知识点记录下来,以便下次开发的时候查看. 开发小程序开发工具推荐vscode写代码,微信开发工具用于查看效果 ...

  5. 微信小程序开发笔记 进阶篇④——getPhoneNumber 获取用户手机号码(小程序云)

    文章目录 一.前言 二.前端代码wxml 三.前端代码js 四.云函数 五.程序流程 一.前言 微信小程序开发笔记--导读 大部分微信小程序开发者都会有这样的需求:获取小程序用户的手机号码. 但是,因 ...

  6. 微信小程序开发笔记 进阶篇⑤——getPhoneNumber 获取用户手机号码(基础库 2.21.2 之前)

    文章目录 一.前言 二.前端代码wxml 三.前端代码js 四.后端java 五.程序流程 六.参考 一.前言 微信小程序开发笔记--导读 大部分微信小程序开发者都会有这样的需求:获取小程序用户的手机 ...

  7. 微信小程序开发笔记二(WXSS和CSS样式美化)

    微信小程序开发笔记二(WXSS和CSS样式美化) 一.CSS基本知识 1.Class选择器的定义 2.ID选择器的定义 3.ID选择器和class选择器的区别 4.CSS中设置颜色 5.CSS中的文本 ...

  8. 微信小程序开发笔记——wsdchong

    微信小程序开发笔记 一.小程序简介 小程序起源于微信的webview:此类API最初是提供给腾讯内部一些业务使用,很多外部开发者发现后,照葫芦画瓢,逐渐成为微信中网页的事实标准.2015年初,微信发布 ...

  9. 微信小程序开发笔记 进阶篇⑥——getPhoneNumber 获取用户手机号码(基础库 2.21.2 之后)

    文章目录 一.前言 二.前端代码wxml 三.前端代码js 四.后端java 五.程序流程 六.参考 一.前言 微信小程序开发笔记--导读 大部分微信小程序开发者都会有这样的需求:获取小程序用户的手机 ...

最新文章

  1. 2021湖南高考成绩分段查询,2021年湖南高考分数一分一段位次表,湖南高考个人成绩排名查询方法...
  2. (0016)iOS 开发之Mac上Navicat Premium 创建远程连接和本地连接
  3. 当DiscuzNT遇上了Loadrunner(中)
  4. 关于如何生成随机记录
  5. 常见证书格式及相互转换
  6. php给留言分配id_php留言板更新代码
  7. 移动玩具(信息学奥赛一本通-T1453)
  8. 11.2. simpara
  9. perl,shell中如何打印出处理sql语句变量的单引号
  10. 利用nginx+lua+redis实现反向代理方法教程
  11. Spring源码之bean的加载(四)获取单例
  12. 安卓问题报告小记(四):Some projects cannot be imported because they already exist in the workspace...
  13. 【渝粤题库】陕西师范大学163104 景区管理 作业 (高起专)
  14. 2013年IT TOP100
  15. Android Title标题栏的修改(隐藏,菜单)
  16. 力扣刷题 DAY_82 贪心
  17. TPC TiKV:Hackathon 中最硬核项目是如何炼成的?| TPC 战队访谈
  18. 上半年要完成的博客51
  19. 架构 | 如何从零开始搭建高性能直播平台?
  20. 【WebView】关于Android WebView 的一些坑

热门文章

  1. App打包原理——Android项目构建过程
  2. 百度智能云联手Pixellot创新中国大众体育传播新模式
  3. 最简单易懂,什么是Node.js
  4. Word基础(十九)稿纸设置
  5. 使用Java语言从零开始创建区块连
  6. DELPHI定义的条件编译的全部说明
  7. Conflux斩获中国品牌经济峰会 “2019区块链产业最具竞争力项目”大奖
  8. 当贝叶斯,奥卡姆和香农一起来定义机器学习
  9. 荧光染料CY3/CY5/CY5.5聚已内酯PLA载药纳米粒CY3-PLA|CY5-SS-PEG-PLA|CY5.5-PLA(定制供应)
  10. XT4054 锂电池充电IC