编写高质量JavaScript代码之并发

参考书籍:《Effective JavaScript》程序员

并发

在JavaScript中,编写响应多个并发事件的程序的方法很是人性化,并且强大,由于它使用了一个简单的执行模型(有时称为事件队列或事件循环并发)和被称为异步的API。算法

不要阻塞I/O事件队列

在一些语言中,咱们会习惯性地编写代码来等待某个特定的输入。编程

var text = downloadSync('http://example.com/file.txt');
console.log(text);

形如downloadSync这样的函数被称为同步函数(或阻塞函数)。程序会中止作任何工做,而等待它的输入。在这个例子中,也就是等待从网络下载文件的结果。因为在等待下载完成的期间,计算机能够作其余有用的工做,所以这样的语言一般为程序员提供一种方法来建立多个线程,即并行执行本身算。它容许程序的一部分停下来等待(阻塞)一个低速的输入,而程序的另外一部分能够继续进行独立的工做。数组

在JavaScript中,大多的I/O操做都提供了异步的或非阻塞的API。promise

downloadAsync('http://example.com/file.txt', function (text) {
    console.log(text);
});

该API初始化下载进程,而后在内部注册表中存储了回调函数后马上返回,而不是被网络请求阻塞。浏览器

JavaScript有时被称为提供一个运行到完成机制(run-to-completion)的担保。任何当前正在运行于共享上下文的用户代码,好比浏览器中的单个Web页面或者单个运行的Web服务器实例,只有在执行完成后才能调用下一个事件处理程序。实际上,系统维护了一个按事件发生顺序排列的内部事件队列,一次调用一个已注册的回调函数。缓存

以客户端(mouse moved、file downloaded)和服务器端(file read、path resolved)应用程序事件为例,随着事件的发生,它们被添加到应用程序的事件队列的末尾。JavaScript系统使用一个内部循环机制来执行应用程序。该循环机制每次都拉取队列底部的事件,也就是说,以接收到这些事件的顺序来调用这些已注册的JavaScript事件处理程序,并将事件的数据做为改事件处理程序的参数。安全

运行到完成机制担保的好处是当代码运行时,你彻底掌握应用程序的状态。你根本没必要担忧一些变量和对象属性的改变因为并发执行代码而超出你的控制。并发编程在JavaScript中每每比使用线程和锁的C++、Java或C#要容易得多。服务器

然而,运行到完成机制的不足是,实际上全部你编写的代码支撑着余下应用程序的继续执行。网络

JavaScript并发的一个最重要的规则是毫不要在应用程序事件队列中使用阻塞I/O的API。

相比之下,异步的API用在基于事件的环境中是安全的,由于它们迫使应用程序逻辑在一个独立的事件循环“轮询”中继续处理。

提示:

  • 异步APi使用回调函数来延缓处理代价高昂的操做以免阻塞主应用程序。
  • JavaScript并发地接收事件,但会使用一个事件队列按序地处理事件处理程序。
  • 在应用程序事件队列中毫不要使用阻塞的I/O。

在异步序列中使用嵌套或命名的回调函数

理解操做序列的最简单的方式是异步API是发起操做而不是执行操做。异步操做完成后,在事件循环的某个单独的轮次中,被注册的事件处理程序才会执行。

若是你须要在发起一个操做后作一些事情,如何串联已完成的异步操做。

  • 最简单的答案是使用嵌套。

    db.lookupAsyc('url', function(url) {
        downloadAsyc(url, function(text) {
            console.log('contents of ' + url + ': ' + text);
        });
    });

    嵌套的异步操做很容易,但当扩展到更长的序列时会很快变得笨拙。

    db.lookupAsync('url', function(url) {
        downloadAsync(url, function(file) {
            downloadAsync('a.txt', function(a) {
                downladAsync('b.txt', function(b) {
                    downloadAsync('c.txt', function(c) {
                        // ...
                    });
                })
            });
        });
    });
  • 减小过多嵌套的方法之一是将嵌套的回调函数做为命名的函数,并将它们须要的附加数据做为额外的参数传递。

    db.lookupAsync('url', downloadURL);
    
    function downloadURL(url) {
        downloadAsync(url, function(text) { // still nested
            showContents(url, text);
        });
    }
    
    function showContents(url, text) {
        console.log('contents of ' + url + ': ' + text);
    }

    上述代码仍然使用了嵌套的回调函数,可使用bind方法消除最深层的嵌套回调函数。

    db.lookupAsync('url', downloadURL);
    
    function downloadURL(url) {
        downloadAsync(url, showContents.bind(null, url)); // => window.showContents(url) = function(url, text) { ... } 
    }
    
    function showContents(url, text) {
        console.log('contents of ' + url + ': ' + text);
    }

    这种作法致使了代码看起来根据顺序性,但须要为操做序列的每一个中间步骤命名,而且一步步地使用绑定,这可能致使尴尬的状况。

  • 更胜一筹的方法是使用一个额外的抽象来简化。

    function downloadFiles(url, file) {
        downloadAllAsync(['a.txt', 'b.txt', 'c.txt'], function(all) {
            var a = all[0],
                b = all[1],
                c = all[2];
            
            // ...
        });
    }

提示:

  • 使用嵌套或命名的回调函数按顺序地执行多个异步操做。
  • 尝试在过多的嵌套的回调函数和尴尬的命名的非嵌套回调函数之间取得平衡。
  • 避免将可被并行执行的操做顺序化。

小心丢弃错误

对于同步的代码,经过使用try语句块包装一段代码很容易一会儿处理全部的错误。

try {
    f();
    g();
    h();
} catch (e) {
    // handle any error that occurred...
}

异步的API倾向于将错误表示为回调函数的特定参数,或使用一个附加的错误处理回调函数(有时被称为errbacks)。

downloadAsync('a.txt', function(a) {
    downloadAsync('b.txt', function(b) {
        downloadAsync('c.txt', function(c) {
            console.log('Content: ' + a + b + c);   
        }, function(error) {
            console.log('Error: ' + error);
        })
    }, function(error) { // repeated error-handling logic
        console.log('Error: ' + error);
    })
}, function(error) { // repeated error-handling logic
    console.log('Error: ' + error);
})

上述代码中,每一步的处理都使用了相同的错误处理逻辑,咱们能够在一个共享的做用域中定义一个错误处理的函数,将重复代码抽象出来。

function onError(error) {
    console.log('Error: ' + error);
}

downloadAsync('a.txt', function(a) {
    downloadAsync('b.txt', function(b) {
        downloadAsync('c.txt', function(c) {
            console.log('Content: ' + a + b + c);   
        }, onError)
    }, onError)
}, onError)

另外一种错误处理API的风格只须要一个回调函数,该回调函数的第一个参数若是有错误发生那就表示为一个错误,不然就位一个假值,好比null。

function onError(error) {
    console.log('Error: ' + error);
}

downloadAsync('a.txt', function(error, a) {
    if (error) return onError(error);

    downloadAsync('b.txt', function(error, b) {
        if (error) return onError(error);

        downloadAsync(url13, function(error, c) {
            if (error) return onError(error);

            console.log('Content: ' + a + b + c);
        });
    });
});

提示:

  • 经过编写共享的错误处理函数来避免复制和粘贴错误处理代码。
  • 确保明确地处理全部的错误条件以免丢弃错误。

对异步循环使用递归

设想有一个函数接收一个URL的数组并尝试依次下载每一个文件。

function downloadOneSync(urls) {
    for (var i = 0, n = urls.length; i < n; i++) {
        downloadAsync(urls[i], onsuccess, function(error) {
            // ?
        });

        // loop continues
    }

    throw new Error('all downloads failed');
}

上述代码将启动全部的下载,而不是等待一个完成再试下一个。

解决方案是将循环实现为一个函数,因此咱们能够决定什么时候开始每次迭代。

function downloadOneAsync(urls, onsuccess, onfailure) {
    var n = urls.length;

    function tryNextURL(i) {
        if (i >= n) {
            onfailure('all downloads failed');
            return;
        }

        downloadAsync(urls[i], onsuccess, function() {
            tryNextURL(i + 1);
        });
    }

    tryNextURL(0);
}

局部函数tryNextURL是一个递归函数。它的实现调用了其自身。目前典型的JavaScript环境中一个递归函数同步调用自身过屡次(例如10万次)会致使失败。

JavaScript环境一般在内存中保存一块固定的区域,称为调用栈,用于记录函数调用返回前下一步该作什么。

function negative(x) {
    return abs(x) * -1;
}

function abs(x) {
    return Math.abs(x);
}

console.log(negative(42));

当程序使用参数42调用Math.abs方法时,有好几个其余的函数调用也在进行,每一个都在等待另外一个的调用返回。

最新的函数调用将信息推入栈(被表示为栈的最底层的帧),该信息也将首先从栈中弹出。当Math.abs执行完毕,将会返回给abs函数,其将返回给negative函数,而后将返回到最外面的脚本。

当一个程序执行中有太多的函数调用,它会耗尽栈空间,最终抛出异常,这种状况被称为栈溢出。

downloadOneAsync函数,不是直到递归调用返回后才被返回,downloadOneAsync只在异步回调函数中调用自身。记住异步API在其回调函数被调用前会当即返回。因此downloadOneAsync返回,致使其栈帧在任何递归调用将新的栈帧推入栈前,会从调用栈中弹出。事实上,回调函数总在事件循环的单独轮次中被调用,事件循环的每一个轮次中调用其事件处理程序的调用栈最初是空的。因此不管downloadOneAsync须要多少次迭代,都不会耗尽栈空间。

提示:

  • 循环不能是异步的。
  • 使用递归函数在事件循环的单独轮次中执行迭代。
  • 在事件循环的单独轮次中执行递归,并不会致使调用栈溢出。

不要在计算时阻塞事件队列

若是你的应用程序须要执行代价高昂的算法你该怎么办呢?

也许最简单的方法是使用像Web客户端平台的Worker API这样的并发机制。

可是不是全部的JavaScript平台都提供了相似Worker这样的API,另外一种方法是算法分解为多个步骤,每一个步骤组成一个可管理的工做块。

Member.prototype.inNetwork = function(other){
    var visited = {},
        worklist = [this];

    while (worklist.length > 0) {
        var member = worklist.pop();

        // ...

        if (member === other) { // found?
            return true;
        }

        // ...
    }

    return false;
};

若是这段程序核心的while循环代价太太高昂,搜索工做极可能会以不可接受的时间运行而阻塞应用程序事件队列。

幸运的是,这种算法被定义为一个步骤集的序列——while循环的迭代。咱们能够经过增长一个回调参数将inNetwork转换为一个匿名函数,将while循环替换为一个匿名的递归函数。

Member.prototype.inNetwork = function(other, callback) {
    var visited = {},
        worklist = [this];

    function next() {
        if (worklist.length === 0) {
            callback(false);
            return;
        }

        var number = worklist.pop();

        // ...

        if (member === other) { // found?
            callback(true);
            return;
        }

        // ...

        setTimeout(next, 0); // schedule the next iteration
    }

    setTimeout(next, 0); // schedule the next iteration
};

局部的next函数执行循环中的单个迭代而后调度应用程序事件队列来异步运行下一次迭代。这使得在此期间已经发生的其余事件被处理后才继续下一次迭代。当搜索完成后,经过找到一个匹配或遍历完整个工做表,咱们使用结果值调用回调函数并经过调用没有调度任何迭代的next来返回,从而有效地完成循环。

要调度迭代,咱们使用多数JavaScript平台均可用的、通用的setTimeout API来注册next函数,是next函数通过一段最少时间(0毫秒)后运行。这具备几乎马上将回调函数添加到事件队列上的做用。

提示:

  • 避免在主事件队列中执行代价高昂的算法。
  • 在支持Worker API的平台,该API能够用来在一个独立的事件队列中运行长计算程序。
  • 在Worker API不可用或代价昂贵的环境中,考虑将计算程序分解到事件循环的多个轮次中。

使用计数器来执行并行操做

function downloadAllAsync(urls, onsuccess, onerror) {
    var result = [],
        length = urls.length;

    if (length === 0) {
        setTimeout(onsuccess.bind(null, result), 0);
        return;
    }

    urls.forEach(function(url) {
        downloadAsync(url, function(text) {
            if (result) {
                // race condition
                reuslt.push(text);

                if (result.length === urls.length) {
                    onsuccess(result);
                }
            }
        }, function(error) {
            if (result) {
                result = null;
                onerror(error);
            }
        });
    });
}

上述代码有错误。

当一个应用程序依赖于特定的事件顺序才能正常工做时,这个程序会遭受数据竞争(data race)。数据竞争是指多个并发操做能够修改共享的数据结构,这取决于它们发生的顺序。

var filenames = [
    'huge.txt',
    'tiny.txt',
    'medium.txt'
];

downloadAllAsync(filenames, function(files) {
    console.log('Huge file: ' + files[0].length); // tiny
    console.log('Tiny file: ' + files[1].length); // medium
    console.log('Medium file: ' + files[2].length); // huge
}, function(error) {
    console.log('Error: ' + error);
});

因为这些文件是并行下载的,事件能够以任意的顺序发生。例如,若是tiny.txt先下载完成,接下来是medium.txt文件,最后是huge.txt文件,则注册到downloadAllAsync的回调函数并不会按照它们被建立的顺序进行调用。但downloadAllAsync的实现是一旦下载完成就当即将中间结果保存在result数组的末尾。因此downloadAllAsync函数提供的保存下载文件内容的数组的顺序是未知的。

下面的方式能够实现downloadAllAsync不依赖不可预期的事件执行顺序而总能提供预期结果。咱们不将每一个结果放置到数组末尾,而是存储在其原始的索引位置中。

function downloadAsync(urls, onsuccess, onerror) {
    var length = urls.length,
        result = [];
    
    if (length === 0) {
        setTimeout(onsuccess.bind(null, result), 0);
        return;
    }

    urls.forEach(function(url, i) {
        downloadAsync(url, function(text) {
            if (result) {
                result[i] = text; // store at fixed index

                // race condition
                if (result.length === urls.length) {
                    onsuccess(result);
                }
            }
        }, function(error) {
            if (result) {
                result = null;
                onerror(error);
            }
        });
    });
}

上述代码依然是不正确的。

假如咱们有以下的一个请求。

downloadAllAsync(['huge.txt', 'medium.txt', 'tiny.txt']);

根据数组更新的契约,即设置一个索引属性,老是确保数组的length属性值大于索引。

若是tiny.txt文件最早被下载,结果数组将获取索引未2的属性,这将致使result.length被更新为3。用户的success回调函数被过早地调用,其参数为一个不完整的结果数组。

正确地实现应该是使用一个计数器来追踪正在进行的操做数量。

function downloadAsync(urls, onsuccess, onerror) {
    var pending = urls.length,
        result = [];
    
    if (pending === 0) {
        setTimeout(onsuccess.bind(null, result), 0);
        return;
    }

    urls.forEach(function(url, i) {
        downloadAsync(url, function(text) {
            if (result) {
                result[i] = text; // store at fixed index
                pending--; // register the success

                // race condition
                if (pedding === 0) {
                    onsuccess(result);
                }
            }
        }, function(error) {
            if (result) {
                result = null;
                onerror(error);
            }
        });
    });
}

提示:

  • JavaScript应用程序中的事件发生是不肯定的,即顺序是不可预测的。
  • 使用计数器避免并行操做中的数据竞争。

毫不要同步地调用异步的回调函数

设想有downloadAsync函数的一个变种,它持有一个缓存来避免屡次下载同一个文件。

var cache = new Dict();

function downloadCachingAsync(url, onsuccess, onerror) {
    if (cache.has(url)) {
        onsuccess(cache.get(url)); // synchronous call
        return;
    }

    return downloadAsync(url, function(file) {
        cache.set(url, file);
        onsuccess(file);
    }, onerror);
};

一般状况下,若是能够,它彷佛会当即提供数据,但这以微妙的方式违反了异步API客户端的指望。

  • 首先,它改变了操做的预期顺序。

    downloadCachingAsync('file.txt', function(file) {
        console.log('finished'); // might happen first
    });
    
    console.log('starting');
  • 其次,异步API的目的是维持事件循环中每轮的严格分离。这简化了并发,经过减轻每轮事件循环的代码量而没必要担忧其余代码并发地修改共享的数据结构。同步地调用异步的回调函数违反了这一分离,致使在当前轮完成以前,代码用于执行一轮隔离的事件循环。

    downloadCachingAsync(remaining[0], function(file) {
        remaing.shift();
    
        // ...
    });
    
    status.display('Downloading ' + remaining[0] + '...');

    若是同步地调用该回调函数,那么将显示错误的文件名的消息(或者更糟糕的是,若是队列为空会显示undefined)。

同步地调用异步的回调函数甚至可能会致使一些微妙的问题。

  • 异步的回调函数本质上是以空的调用栈来调用,所以将异步的循环实现为递归函数是安全的,彻底没有累计超越调用栈空间的危险。同步的调用不能保障这一点,于是使得一个表面上的异步循环极可能会耗尽调用栈空间。
  • 另外一个问题是异常。对于上面的downloadCachingAsync实现,若是回调函数抛出一个异常,它将会在每轮的事件循环中,也就是开始下载时而不是指望的一个分离的回合抛出该异常。

为了确保老是异步地调用回调函数,咱们可使用通用的库函数setTimeout在每隔一个最小的时间的超时时间后给事件队列增长一个回调函数。

var cache = new Dict();

function downloadCachingAsync(url, onsuccess, onerror) {
    if (cache.has(url)) {
        var cached = cache.get(url);
        setTimeout(onsuccess.bind(null, cached), 0);
        return;
    }

    return downloadAsync(url, function(file) {
        cache.set(url, file);
        onsuccess(file);
    }, onerror);
};

提示:

  • 即便能够当即获得数据,也毫不要同步地调用异步回调函数。
  • 同步地调用异步的回调函数扰乱了预期的操做序列,并可能致使意想不到的交错代码。
  • 同步地调用异步的回调函数可能致使栈溢出或错误地处理异常。
  • 使用异步的API,好比setTimeout函数来调度异步回调函数,使其运行于另外一个回合。

使用promise模式清洁异步逻辑

构建异步API的一种流行的替代方式是使用promise(有时也被称为deferred或future)模式。

基于promise的API不接收回调函数做为参数,相反,它返回一个promise对象,该对象经过其自身的then方法接收回调函数。

var p = downloadP('file.txt');

p.then(function(file) {
    console.log('file: ' + file);
});

promise的力量在于它们的组合性。传递给then的回调函数不只产生影响,也能够产生结果。经过回调函数返回一个值,能够构造一个新的promise。

var fileP = downloadP('file.txt');

var lengthP = fileP.then(function(file) {
    return file.length;
});

lengthP.then(function(length) {
    console.log('length: ' + length);
});

promise能够很是容易地构造一个实用程序来拼接多个promise的结果。

var filesP = join(downloadP('file1.txt'), downloadP('file2.txt'), downloadP('file3.txt'));

filesP.then(function(files) {
    console.log('file1: ' + files[0]);
    console.log('file2: ' + files[1]);
    console.log('file3: ' + files[2]);
});

promise库也常常提供一个叫作when的工具函数。

var fileP1 = downloadP('file1.txt'), 
    fileP2 = downloadP('file2.txt'), 
    fileP3 = downloadP('file3.txt');

when([fileP1, fileP2, fileP3], function(files) {
    console.log('file1: ' + files[0]);
    console.log('file2: ' + files[1]);
    console.log('file3: ' + files[2]);
});

使promise成为卓越的抽象层级的部分缘由是经过then方法的返回值来联系结果,或者经过工具函数如join来构成promise,而不是在并行的回调函数间共享数据结构。这本质上是安全的,由于它避免了数据竞争。

有时故意建立某种类的数据竞争是有用的。promise为此提供了一个很好的机制。例如,一个应用程序可能须要尝试从多个不一样的服务器上同时下载同一份文件,而选择最早完成的那个文件。

var fileP = select(downloadP('http://example1.com/file.txt'), 
                    downloadP('http://example1.com/file.txt'),
                    downloadP('http://example1.com/file.txt'));

fileP.then(function(file) {
    console.log('file: ' + file);
});

select函数的另外一个用途是提供超时来终止长时间的操做。

var fileP = select(downloadP('file.txt'), timeoutErrorP(2000));

fileP.then(function(file) {
    console.log('file: ' + file);
}, function(error) {
    console.log('I/O error or timeout: ' + error);
});

提示:

  • promise表明最终值,即并行完成时最终产生的结果。
  • 使用promise组合不一样的并行操做。
  • 使用promise模式的API避免数据竞争。
  • 在要求有意的竞争条件时使用select(也被称为choose)。
相关文章
相关标签/搜索